Compare commits
No commits in common. "401bbf85dcb849f8f4848e4916ccbfeeaa7a89d8" and "9154c9c0caf3879262bb705ab45c95dfbf845c18" have entirely different histories.
401bbf85dc
...
9154c9c0ca
|
|
@ -3450,13 +3450,6 @@
|
||||||
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
|
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/uuid": {
|
|
||||||
"version": "10.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
|
||||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.33",
|
"version": "17.0.33",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
||||||
import externalCallRoutes from "./routes/externalCallRoutes";
|
import externalCallRoutes from "./routes/externalCallRoutes";
|
||||||
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
||||||
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
|
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
|
||||||
import dashboardRoutes from "./routes/dashboardRoutes";
|
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
|
|
@ -172,7 +171,6 @@ app.use("/api/entity-reference", entityReferenceRoutes);
|
||||||
app.use("/api/external-calls", externalCallRoutes);
|
app.use("/api/external-calls", externalCallRoutes);
|
||||||
app.use("/api/external-call-configs", externalCallConfigRoutes);
|
app.use("/api/external-call-configs", externalCallConfigRoutes);
|
||||||
app.use("/api/dataflow", dataflowExecutionRoutes);
|
app.use("/api/dataflow", dataflowExecutionRoutes);
|
||||||
app.use("/api/dashboards", dashboardRoutes);
|
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||||
// app.use('/api/users', userRoutes);
|
// app.use('/api/users', userRoutes);
|
||||||
|
|
|
||||||
|
|
@ -1,436 +0,0 @@
|
||||||
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 : '쿼리 실행 오류'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,534 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
/**
|
|
||||||
* 대시보드 관련 타입 정의
|
|
||||||
*/
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import DashboardDesigner from '@/components/admin/dashboard/DashboardDesigner';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 관리 페이지
|
|
||||||
* - 드래그 앤 드롭으로 대시보드 레이아웃 설계
|
|
||||||
* - 차트 및 위젯 배치 관리
|
|
||||||
* - 독립적인 컴포넌트로 구성되어 다른 시스템에 영향 없음
|
|
||||||
*/
|
|
||||||
export default function DashboardPage() {
|
|
||||||
return (
|
|
||||||
<div className="h-full">
|
|
||||||
<DashboardDesigner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,286 +0,0 @@
|
||||||
'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,286 +1,269 @@
|
||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import { useState } from "react";
|
||||||
import Link from 'next/link';
|
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";
|
||||||
|
|
||||||
interface Dashboard {
|
interface UserInfo {
|
||||||
id: string;
|
userId: string;
|
||||||
title: string;
|
userName: string;
|
||||||
description?: string;
|
deptName: string;
|
||||||
thumbnail?: string;
|
email: string;
|
||||||
elementsCount: number;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
isPublic: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface MenuItem {
|
||||||
* 대시보드 목록 페이지
|
id: string;
|
||||||
* - 저장된 대시보드들의 목록 표시
|
title: string;
|
||||||
* - 새 대시보드 생성 링크
|
icon: any;
|
||||||
* - 대시보드 미리보기 및 관리
|
children?: MenuItem[];
|
||||||
*/
|
}
|
||||||
export default function DashboardListPage() {
|
|
||||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
|
|
||||||
// 대시보드 목록 로딩
|
const menuItems: MenuItem[] = [
|
||||||
useEffect(() => {
|
{
|
||||||
loadDashboards();
|
id: "dashboard",
|
||||||
}, []);
|
title: "대시보드",
|
||||||
|
icon: Home,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: "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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const loadDashboards = async () => {
|
export default function DashboardPage() {
|
||||||
setIsLoading(true);
|
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");
|
||||||
|
|
||||||
try {
|
const handleLogout = async () => {
|
||||||
// 실제 API 호출 시도
|
await logout();
|
||||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
};
|
||||||
|
|
||||||
try {
|
const toggleMenu = (menuId: string) => {
|
||||||
const result = await dashboardApi.getDashboards({ page: 1, limit: 50 });
|
const newExpanded = new Set(expandedMenus);
|
||||||
|
if (newExpanded.has(menuId)) {
|
||||||
|
newExpanded.delete(menuId);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(menuId);
|
||||||
|
}
|
||||||
|
setExpandedMenus(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
// API에서 가져온 대시보드들을 Dashboard 형식으로 변환
|
const handleMenuClick = (menuId: string) => {
|
||||||
const apiDashboards: Dashboard[] = result.dashboards.map((dashboard: any) => ({
|
setSelectedMenu(menuId);
|
||||||
id: dashboard.id,
|
setCurrentContent(menuId);
|
||||||
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);
|
const renderContent = () => {
|
||||||
|
switch (currentContent) {
|
||||||
} catch (apiError) {
|
case "dashboard":
|
||||||
console.warn('API 호출 실패, 로컬 스토리지 및 샘플 데이터 사용:', apiError);
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
// API 실패 시 로컬 스토리지 + 샘플 데이터 사용
|
<h1 className="text-3xl font-bold text-slate-900">대시보드</h1>
|
||||||
const savedDashboards = JSON.parse(localStorage.getItem('savedDashboards') || '[]');
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
// 샘플 대시보드들
|
<CardContent className="p-6">
|
||||||
const sampleDashboards: Dashboard[] = [
|
<div className="flex items-center">
|
||||||
{
|
<FileText className="h-8 w-8 text-blue-600" />
|
||||||
id: 'sales-overview',
|
<div className="ml-4">
|
||||||
title: '📊 매출 현황 대시보드',
|
<p className="text-sm font-medium text-slate-600">전체 프로젝트</p>
|
||||||
description: '월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.',
|
<p className="text-2xl font-bold text-slate-900">24</p>
|
||||||
elementsCount: 3,
|
</div>
|
||||||
createdAt: '2024-09-30T10:00:00Z',
|
</div>
|
||||||
updatedAt: '2024-09-30T14:30:00Z',
|
</CardContent>
|
||||||
isPublic: true
|
</Card>
|
||||||
},
|
<Card>
|
||||||
{
|
<CardContent className="p-6">
|
||||||
id: 'user-analytics',
|
<div className="flex items-center">
|
||||||
title: '👥 사용자 분석 대시보드',
|
<Package className="h-8 w-8 text-green-600" />
|
||||||
description: '사용자 행동 패턴 및 가입 추이 분석',
|
<div className="ml-4">
|
||||||
elementsCount: 1,
|
<p className="text-sm font-medium text-slate-600">등록된 부품</p>
|
||||||
createdAt: '2024-09-29T15:00:00Z',
|
<p className="text-2xl font-bold text-slate-900">1,247</p>
|
||||||
updatedAt: '2024-09-30T09:15:00Z',
|
</div>
|
||||||
isPublic: false
|
</div>
|
||||||
},
|
</CardContent>
|
||||||
{
|
</Card>
|
||||||
id: 'inventory-status',
|
<Card>
|
||||||
title: '📦 재고 현황 대시보드',
|
<CardContent className="p-6">
|
||||||
description: '실시간 재고 현황 및 입출고 내역',
|
<div className="flex items-center">
|
||||||
elementsCount: 4,
|
<Users className="h-8 w-8 text-purple-600" />
|
||||||
createdAt: '2024-09-28T11:30:00Z',
|
<div className="ml-4">
|
||||||
updatedAt: '2024-09-29T16:45:00Z',
|
<p className="text-sm font-medium text-slate-600">활성 사용자</p>
|
||||||
isPublic: true
|
<p className="text-2xl font-bold text-slate-900">89</p>
|
||||||
}
|
</div>
|
||||||
];
|
</div>
|
||||||
|
</CardContent>
|
||||||
// 저장된 대시보드를 Dashboard 형식으로 변환
|
</Card>
|
||||||
const userDashboards: Dashboard[] = savedDashboards.map((dashboard: any) => ({
|
<Card>
|
||||||
id: dashboard.id,
|
<CardContent className="p-6">
|
||||||
title: dashboard.title,
|
<div className="flex items-center">
|
||||||
description: dashboard.description,
|
<BarChart3 className="h-8 w-8 text-orange-600" />
|
||||||
elementsCount: dashboard.elements?.length || 0,
|
<div className="ml-4">
|
||||||
createdAt: dashboard.createdAt,
|
<p className="text-sm font-medium text-slate-600">진행중인 작업</p>
|
||||||
updatedAt: dashboard.updatedAt,
|
<p className="text-2xl font-bold text-slate-900">12</p>
|
||||||
isPublic: false // 사용자가 만든 대시보드는 기본적으로 비공개
|
</div>
|
||||||
}));
|
</div>
|
||||||
|
</CardContent>
|
||||||
// 사용자 대시보드를 맨 앞에 배치
|
</Card>
|
||||||
setDashboards([...userDashboards, ...sampleDashboards]);
|
</div>
|
||||||
}
|
<Card>
|
||||||
} catch (error) {
|
<CardContent className="p-6">
|
||||||
console.error('Dashboard loading error:', error);
|
<h2 className="mb-4 text-xl font-semibold">최근 활동</h2>
|
||||||
} finally {
|
<div className="space-y-4">
|
||||||
setIsLoading(false);
|
<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>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">
|
||||||
|
{menuItems.find((item) => item.id === currentContent)?.title ||
|
||||||
|
menuItems.flatMap((item) => item.children || []).find((child) => child.id === currentContent)?.title ||
|
||||||
|
"페이지를 찾을 수 없습니다"}
|
||||||
|
</h1>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-slate-600">{currentContent} 페이지 컨텐츠가 여기에 표시됩니다.</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-400">각 메뉴에 맞는 컴포넌트를 개발하여 연결할 수 있습니다.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 검색 필터링
|
const renderMenuItem = (item: MenuItem, level: number = 0) => {
|
||||||
const filteredDashboards = dashboards.filter(dashboard =>
|
const isExpanded = expandedMenus.has(item.id);
|
||||||
dashboard.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const isSelected = selectedMenu === item.id;
|
||||||
dashboard.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<item.icon className="mr-3 h-4 w-4" />
|
||||||
|
<span className="flex-1">{item.title}</span>
|
||||||
|
{hasChildren && (isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />)}
|
||||||
|
</div>
|
||||||
|
{hasChildren && isExpanded && (
|
||||||
|
<div className="mt-1">{item.children?.map((child) => renderMenuItem(child, level + 1))}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-slate-50">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="bg-white border-b border-gray-200">
|
<header className="border-b border-slate-200 bg-white shadow-sm">
|
||||||
<div className="max-w-7xl mx-auto px-6 py-6">
|
<div className="px-6 py-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div className="flex items-center space-x-4">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">📊 대시보드</h1>
|
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen(!sidebarOpen)}>
|
||||||
<p className="text-gray-600 mt-1">데이터를 시각화하고 인사이트를 얻어보세요</p>
|
<Menu className="h-5 w-5" />
|
||||||
</div>
|
</Button>
|
||||||
|
<h1 className="text-xl font-semibold text-slate-900">PLM 시스템</h1>
|
||||||
<Link
|
|
||||||
href="/admin/dashboard"
|
|
||||||
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium"
|
|
||||||
>
|
|
||||||
➕ 새 대시보드 만들기
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 검색 바 */}
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="relative max-w-md">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="대시보드 검색..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
<div className="absolute left-3 top-2.5 text-gray-400">
|
|
||||||
🔍
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
{/* 메인 콘텐츠 */}
|
<div className="flex">
|
||||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
{/* 사이드바 */}
|
||||||
{isLoading ? (
|
<aside
|
||||||
// 로딩 상태
|
className={`${sidebarOpen ? "w-64" : "w-0"} overflow-hidden border-r border-slate-200 bg-white transition-all duration-300`}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
>
|
||||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
<nav className="space-y-2 p-4">{menuItems.map((item) => renderMenuItem(item))}</nav>
|
||||||
<div key={i} className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
</aside>
|
||||||
<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>
|
<main className="flex-1 p-6">{renderContent()}</main>
|
||||||
<div className="h-3 bg-gray-200 rounded w-2/3 mb-4"></div>
|
|
||||||
<div className="h-32 bg-gray-200 rounded mb-4"></div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/4"></div>
|
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/4"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : filteredDashboards.length === 0 ? (
|
|
||||||
// 빈 상태
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="text-6xl mb-4">📊</div>
|
|
||||||
<h3 className="text-xl font-medium text-gray-700 mb-2">
|
|
||||||
{searchTerm ? '검색 결과가 없습니다' : '아직 대시보드가 없습니다'}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500 mb-6">
|
|
||||||
{searchTerm
|
|
||||||
? '다른 검색어로 시도해보세요'
|
|
||||||
: '첫 번째 대시보드를 만들어보세요'}
|
|
||||||
</p>
|
|
||||||
{!searchTerm && (
|
|
||||||
<Link
|
|
||||||
href="/admin/dashboard"
|
|
||||||
className="inline-flex items-center px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium"
|
|
||||||
>
|
|
||||||
➕ 대시보드 만들기
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// 대시보드 그리드
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{filteredDashboards.map((dashboard) => (
|
|
||||||
<DashboardCard key={dashboard.id} dashboard={dashboard} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardCardProps {
|
|
||||||
dashboard: Dashboard;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 개별 대시보드 카드 컴포넌트
|
|
||||||
*/
|
|
||||||
function DashboardCard({ dashboard }: DashboardCardProps) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow">
|
|
||||||
{/* 썸네일 영역 */}
|
|
||||||
<div className="h-48 bg-gradient-to-br from-blue-50 to-indigo-100 rounded-t-lg flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-4xl mb-2">📊</div>
|
|
||||||
<div className="text-sm text-gray-600">{dashboard.elementsCount}개 요소</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 카드 내용 */}
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex justify-between items-start mb-3">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1">
|
|
||||||
{dashboard.title}
|
|
||||||
</h3>
|
|
||||||
{dashboard.isPublic ? (
|
|
||||||
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">
|
|
||||||
공개
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs bg-gray-100 text-gray-800 px-2 py-1 rounded-full">
|
|
||||||
비공개
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dashboard.description && (
|
|
||||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
|
|
||||||
{dashboard.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 메타 정보 */}
|
|
||||||
<div className="text-xs text-gray-500 mb-4">
|
|
||||||
<div>생성: {new Date(dashboard.createdAt).toLocaleDateString()}</div>
|
|
||||||
<div>수정: {new Date(dashboard.updatedAt).toLocaleDateString()}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 액션 버튼들 */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/${dashboard.id}`}
|
|
||||||
className="flex-1 px-4 py-2 bg-blue-500 text-white text-center rounded-lg hover:bg-blue-600 text-sm font-medium"
|
|
||||||
>
|
|
||||||
보기
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/admin/dashboard?load=${dashboard.id}`}
|
|
||||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm"
|
|
||||||
>
|
|
||||||
편집
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
// 복사 기능 구현
|
|
||||||
console.log('Dashboard copy:', dashboard.id);
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm"
|
|
||||||
title="복사"
|
|
||||||
>
|
|
||||||
📋
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,398 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
|
||||||
import { DashboardElement, QueryResult } from './types';
|
|
||||||
import { ChartRenderer } from './charts/ChartRenderer';
|
|
||||||
|
|
||||||
interface CanvasElementProps {
|
|
||||||
element: DashboardElement;
|
|
||||||
isSelected: boolean;
|
|
||||||
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
|
||||||
onRemove: (id: string) => void;
|
|
||||||
onSelect: (id: string | null) => void;
|
|
||||||
onConfigure?: (element: DashboardElement) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캔버스에 배치된 개별 요소 컴포넌트
|
|
||||||
* - 드래그로 이동 가능
|
|
||||||
* - 크기 조절 핸들
|
|
||||||
* - 삭제 버튼
|
|
||||||
*/
|
|
||||||
export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelect, onConfigure }: CanvasElementProps) {
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
|
||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 });
|
|
||||||
const [resizeStart, setResizeStart] = useState({
|
|
||||||
x: 0, y: 0, width: 0, height: 0, elementX: 0, elementY: 0, handle: ''
|
|
||||||
});
|
|
||||||
const [chartData, setChartData] = useState<QueryResult | null>(null);
|
|
||||||
const [isLoadingData, setIsLoadingData] = useState(false);
|
|
||||||
const elementRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// 요소 선택 처리
|
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
||||||
// 닫기 버튼이나 리사이즈 핸들 클릭 시 무시
|
|
||||||
if ((e.target as HTMLElement).closest('.element-close, .resize-handle')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelect(element.id);
|
|
||||||
setIsDragging(true);
|
|
||||||
setDragStart({
|
|
||||||
x: e.clientX,
|
|
||||||
y: e.clientY,
|
|
||||||
elementX: element.position.x,
|
|
||||||
elementY: element.position.y
|
|
||||||
});
|
|
||||||
e.preventDefault();
|
|
||||||
}, [element.id, element.position.x, element.position.y, onSelect]);
|
|
||||||
|
|
||||||
// 리사이즈 핸들 마우스다운
|
|
||||||
const handleResizeMouseDown = useCallback((e: React.MouseEvent, handle: string) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsResizing(true);
|
|
||||||
setResizeStart({
|
|
||||||
x: e.clientX,
|
|
||||||
y: e.clientY,
|
|
||||||
width: element.size.width,
|
|
||||||
height: element.size.height,
|
|
||||||
elementX: element.position.x,
|
|
||||||
elementY: element.position.y,
|
|
||||||
handle
|
|
||||||
});
|
|
||||||
}, [element.size.width, element.size.height, element.position.x, element.position.y]);
|
|
||||||
|
|
||||||
// 마우스 이동 처리
|
|
||||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
|
||||||
if (isDragging) {
|
|
||||||
const deltaX = e.clientX - dragStart.x;
|
|
||||||
const deltaY = e.clientY - dragStart.y;
|
|
||||||
|
|
||||||
onUpdate(element.id, {
|
|
||||||
position: {
|
|
||||||
x: Math.max(0, dragStart.elementX + deltaX),
|
|
||||||
y: Math.max(0, dragStart.elementY + deltaY)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (isResizing) {
|
|
||||||
const deltaX = e.clientX - resizeStart.x;
|
|
||||||
const deltaY = e.clientY - resizeStart.y;
|
|
||||||
|
|
||||||
let newWidth = resizeStart.width;
|
|
||||||
let newHeight = resizeStart.height;
|
|
||||||
let newX = resizeStart.elementX;
|
|
||||||
let newY = resizeStart.elementY;
|
|
||||||
|
|
||||||
switch (resizeStart.handle) {
|
|
||||||
case 'se': // 오른쪽 아래
|
|
||||||
newWidth = Math.max(150, resizeStart.width + deltaX);
|
|
||||||
newHeight = Math.max(150, resizeStart.height + deltaY);
|
|
||||||
break;
|
|
||||||
case 'sw': // 왼쪽 아래
|
|
||||||
newWidth = Math.max(150, resizeStart.width - deltaX);
|
|
||||||
newHeight = Math.max(150, resizeStart.height + deltaY);
|
|
||||||
newX = resizeStart.elementX + deltaX;
|
|
||||||
break;
|
|
||||||
case 'ne': // 오른쪽 위
|
|
||||||
newWidth = Math.max(150, resizeStart.width + deltaX);
|
|
||||||
newHeight = Math.max(150, resizeStart.height - deltaY);
|
|
||||||
newY = resizeStart.elementY + deltaY;
|
|
||||||
break;
|
|
||||||
case 'nw': // 왼쪽 위
|
|
||||||
newWidth = Math.max(150, resizeStart.width - deltaX);
|
|
||||||
newHeight = Math.max(150, resizeStart.height - deltaY);
|
|
||||||
newX = resizeStart.elementX + deltaX;
|
|
||||||
newY = resizeStart.elementY + deltaY;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdate(element.id, {
|
|
||||||
position: { x: Math.max(0, newX), y: Math.max(0, newY) },
|
|
||||||
size: { width: newWidth, height: newHeight }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isDragging, isResizing, dragStart, resizeStart, element.id, onUpdate]);
|
|
||||||
|
|
||||||
// 마우스 업 처리
|
|
||||||
const handleMouseUp = useCallback(() => {
|
|
||||||
setIsDragging(false);
|
|
||||||
setIsResizing(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 전역 마우스 이벤트 등록
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isDragging || isResizing) {
|
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
|
|
||||||
|
|
||||||
// 데이터 로딩
|
|
||||||
const loadChartData = useCallback(async () => {
|
|
||||||
if (!element.dataSource?.query || element.type !== 'chart') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoadingData(true);
|
|
||||||
try {
|
|
||||||
// console.log('🔄 쿼리 실행 시작:', element.dataSource.query);
|
|
||||||
|
|
||||||
// 실제 API 호출
|
|
||||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
|
||||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
|
||||||
|
|
||||||
// console.log('✅ 쿼리 실행 결과:', result);
|
|
||||||
|
|
||||||
setChartData({
|
|
||||||
columns: result.columns || [],
|
|
||||||
rows: result.rows || [],
|
|
||||||
totalRows: result.rowCount || 0,
|
|
||||||
executionTime: 0
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// console.error('❌ 데이터 로딩 오류:', error);
|
|
||||||
setChartData(null);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingData(false);
|
|
||||||
}
|
|
||||||
}, [element.dataSource?.query, element.type, element.subtype]);
|
|
||||||
|
|
||||||
// 컴포넌트 마운트 시 및 쿼리 변경 시 데이터 로딩
|
|
||||||
useEffect(() => {
|
|
||||||
loadChartData();
|
|
||||||
}, [loadChartData]);
|
|
||||||
|
|
||||||
// 자동 새로고침 설정
|
|
||||||
useEffect(() => {
|
|
||||||
if (!element.dataSource?.refreshInterval || element.dataSource.refreshInterval === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const interval = setInterval(loadChartData, element.dataSource.refreshInterval);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [element.dataSource?.refreshInterval, loadChartData]);
|
|
||||||
|
|
||||||
// 요소 삭제
|
|
||||||
const handleRemove = useCallback(() => {
|
|
||||||
onRemove(element.id);
|
|
||||||
}, [element.id, onRemove]);
|
|
||||||
|
|
||||||
// 스타일 클래스 생성
|
|
||||||
const getContentClass = () => {
|
|
||||||
if (element.type === 'chart') {
|
|
||||||
switch (element.subtype) {
|
|
||||||
case 'bar': return 'bg-gradient-to-br from-indigo-400 to-purple-600';
|
|
||||||
case 'pie': return 'bg-gradient-to-br from-pink-400 to-red-500';
|
|
||||||
case 'line': return 'bg-gradient-to-br from-blue-400 to-cyan-400';
|
|
||||||
default: return 'bg-gray-200';
|
|
||||||
}
|
|
||||||
} else if (element.type === 'widget') {
|
|
||||||
switch (element.subtype) {
|
|
||||||
case 'exchange': return 'bg-gradient-to-br from-pink-400 to-yellow-400';
|
|
||||||
case 'weather': return 'bg-gradient-to-br from-cyan-400 to-indigo-800';
|
|
||||||
default: return 'bg-gray-200';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'bg-gray-200';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={elementRef}
|
|
||||||
className={`
|
|
||||||
absolute bg-white border-2 rounded-lg shadow-lg
|
|
||||||
min-w-[150px] min-h-[150px] cursor-move
|
|
||||||
${isSelected ? 'border-green-500 shadow-green-200' : 'border-gray-600'}
|
|
||||||
`}
|
|
||||||
style={{
|
|
||||||
left: element.position.x,
|
|
||||||
top: element.position.y,
|
|
||||||
width: element.size.width,
|
|
||||||
height: element.size.height
|
|
||||||
}}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="bg-gray-50 p-3 border-b border-gray-200 flex justify-between items-center cursor-move">
|
|
||||||
<span className="font-bold text-sm text-gray-800">{element.title}</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{/* 설정 버튼 */}
|
|
||||||
{onConfigure && (
|
|
||||||
<button
|
|
||||||
className="
|
|
||||||
w-6 h-6 flex items-center justify-center
|
|
||||||
text-gray-400 hover:bg-blue-500 hover:text-white
|
|
||||||
rounded transition-colors duration-200
|
|
||||||
"
|
|
||||||
onClick={() => onConfigure(element)}
|
|
||||||
title="설정"
|
|
||||||
>
|
|
||||||
⚙️
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* 삭제 버튼 */}
|
|
||||||
<button
|
|
||||||
className="
|
|
||||||
element-close w-6 h-6 flex items-center justify-center
|
|
||||||
text-gray-400 hover:bg-red-500 hover:text-white
|
|
||||||
rounded transition-colors duration-200
|
|
||||||
"
|
|
||||||
onClick={handleRemove}
|
|
||||||
title="삭제"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 내용 */}
|
|
||||||
<div className="h-[calc(100%-45px)] relative">
|
|
||||||
{element.type === 'chart' ? (
|
|
||||||
// 차트 렌더링
|
|
||||||
<div className="w-full h-full bg-white">
|
|
||||||
{isLoadingData ? (
|
|
||||||
<div className="w-full h-full flex items-center justify-center text-gray-500">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
|
||||||
<div className="text-sm">데이터 로딩 중...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ChartRenderer
|
|
||||||
element={element}
|
|
||||||
data={chartData}
|
|
||||||
width={element.size.width}
|
|
||||||
height={element.size.height - 45}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// 위젯 렌더링 (기존 방식)
|
|
||||||
<div className={`
|
|
||||||
w-full h-full p-5 flex items-center justify-center
|
|
||||||
text-sm text-white font-medium text-center
|
|
||||||
${getContentClass()}
|
|
||||||
`}>
|
|
||||||
<div>
|
|
||||||
<div className="text-4xl mb-2">
|
|
||||||
{element.type === 'widget' && element.subtype === 'exchange' && '💱'}
|
|
||||||
{element.type === 'widget' && element.subtype === 'weather' && '☁️'}
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-pre-line">{element.content}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 리사이즈 핸들 (선택된 요소에만 표시) */}
|
|
||||||
{isSelected && (
|
|
||||||
<>
|
|
||||||
<ResizeHandle position="nw" onMouseDown={handleResizeMouseDown} />
|
|
||||||
<ResizeHandle position="ne" onMouseDown={handleResizeMouseDown} />
|
|
||||||
<ResizeHandle position="sw" onMouseDown={handleResizeMouseDown} />
|
|
||||||
<ResizeHandle position="se" onMouseDown={handleResizeMouseDown} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResizeHandleProps {
|
|
||||||
position: 'nw' | 'ne' | 'sw' | 'se';
|
|
||||||
onMouseDown: (e: React.MouseEvent, handle: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 크기 조절 핸들 컴포넌트
|
|
||||||
*/
|
|
||||||
function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
|
|
||||||
const getPositionClass = () => {
|
|
||||||
switch (position) {
|
|
||||||
case 'nw': return 'top-[-5px] left-[-5px] cursor-nw-resize';
|
|
||||||
case 'ne': return 'top-[-5px] right-[-5px] cursor-ne-resize';
|
|
||||||
case 'sw': return 'bottom-[-5px] left-[-5px] cursor-sw-resize';
|
|
||||||
case 'se': return 'bottom-[-5px] right-[-5px] cursor-se-resize';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
resize-handle absolute w-3 h-3 bg-green-500 border border-white
|
|
||||||
${getPositionClass()}
|
|
||||||
`}
|
|
||||||
onMouseDown={(e) => onMouseDown(e, position)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 샘플 데이터 생성 함수 (실제 API 호출 대신 사용)
|
|
||||||
*/
|
|
||||||
function generateSampleData(query: string, chartType: string): QueryResult {
|
|
||||||
// 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
|
|
||||||
const isMonthly = query.toLowerCase().includes('month');
|
|
||||||
const isSales = query.toLowerCase().includes('sales') || query.toLowerCase().includes('매출');
|
|
||||||
const isUsers = query.toLowerCase().includes('users') || query.toLowerCase().includes('사용자');
|
|
||||||
const isProducts = query.toLowerCase().includes('product') || query.toLowerCase().includes('상품');
|
|
||||||
|
|
||||||
let columns: string[];
|
|
||||||
let rows: Record<string, any>[];
|
|
||||||
|
|
||||||
if (isMonthly && isSales) {
|
|
||||||
// 월별 매출 데이터
|
|
||||||
columns = ['month', 'sales', 'order_count'];
|
|
||||||
rows = [
|
|
||||||
{ month: '2024-01', sales: 1200000, order_count: 45 },
|
|
||||||
{ month: '2024-02', sales: 1350000, order_count: 52 },
|
|
||||||
{ month: '2024-03', sales: 1180000, order_count: 41 },
|
|
||||||
{ month: '2024-04', sales: 1420000, order_count: 58 },
|
|
||||||
{ month: '2024-05', sales: 1680000, order_count: 67 },
|
|
||||||
{ month: '2024-06', sales: 1540000, order_count: 61 },
|
|
||||||
];
|
|
||||||
} else if (isUsers) {
|
|
||||||
// 사용자 가입 추이
|
|
||||||
columns = ['week', 'new_users'];
|
|
||||||
rows = [
|
|
||||||
{ week: '2024-W10', new_users: 23 },
|
|
||||||
{ week: '2024-W11', new_users: 31 },
|
|
||||||
{ week: '2024-W12', new_users: 28 },
|
|
||||||
{ week: '2024-W13', new_users: 35 },
|
|
||||||
{ week: '2024-W14', new_users: 42 },
|
|
||||||
{ week: '2024-W15', new_users: 38 },
|
|
||||||
];
|
|
||||||
} else if (isProducts) {
|
|
||||||
// 상품별 판매량
|
|
||||||
columns = ['product_name', 'total_sold', 'revenue'];
|
|
||||||
rows = [
|
|
||||||
{ product_name: '스마트폰', total_sold: 156, revenue: 234000000 },
|
|
||||||
{ product_name: '노트북', total_sold: 89, revenue: 178000000 },
|
|
||||||
{ product_name: '태블릿', total_sold: 134, revenue: 67000000 },
|
|
||||||
{ product_name: '이어폰', total_sold: 267, revenue: 26700000 },
|
|
||||||
{ product_name: '스마트워치', total_sold: 98, revenue: 49000000 },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
// 기본 샘플 데이터
|
|
||||||
columns = ['category', 'value', 'count'];
|
|
||||||
rows = [
|
|
||||||
{ category: 'A', value: 100, count: 10 },
|
|
||||||
{ category: 'B', value: 150, count: 15 },
|
|
||||||
{ category: 'C', value: 120, count: 12 },
|
|
||||||
{ category: 'D', value: 180, count: 18 },
|
|
||||||
{ category: 'E', value: 90, count: 9 },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
columns,
|
|
||||||
rows,
|
|
||||||
totalRows: rows.length,
|
|
||||||
executionTime: Math.floor(Math.random() * 100) + 50, // 50-150ms
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,262 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
|
||||||
import { ChartConfig, QueryResult } from './types';
|
|
||||||
|
|
||||||
interface ChartConfigPanelProps {
|
|
||||||
config?: ChartConfig;
|
|
||||||
queryResult?: QueryResult;
|
|
||||||
onConfigChange: (config: ChartConfig) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 차트 설정 패널 컴포넌트
|
|
||||||
* - 데이터 필드 매핑 설정
|
|
||||||
* - 차트 스타일 설정
|
|
||||||
* - 실시간 미리보기
|
|
||||||
*/
|
|
||||||
export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartConfigPanelProps) {
|
|
||||||
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
|
||||||
|
|
||||||
// 설정 업데이트
|
|
||||||
const updateConfig = useCallback((updates: Partial<ChartConfig>) => {
|
|
||||||
const newConfig = { ...currentConfig, ...updates };
|
|
||||||
setCurrentConfig(newConfig);
|
|
||||||
onConfigChange(newConfig);
|
|
||||||
}, [currentConfig, onConfigChange]);
|
|
||||||
|
|
||||||
// 사용 가능한 컬럼 목록
|
|
||||||
const availableColumns = queryResult?.columns || [];
|
|
||||||
const sampleData = queryResult?.rows?.[0] || {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="text-lg font-semibold text-gray-800">⚙️ 차트 설정</h4>
|
|
||||||
|
|
||||||
{/* 쿼리 결과가 없을 때 */}
|
|
||||||
{!queryResult && (
|
|
||||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|
||||||
<div className="text-yellow-800 text-sm">
|
|
||||||
💡 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 차트를 설정할 수 있습니다.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 데이터 필드 매핑 */}
|
|
||||||
{queryResult && (
|
|
||||||
<>
|
|
||||||
{/* 차트 제목 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">차트 제목</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={currentConfig.title || ''}
|
|
||||||
onChange={(e) => updateConfig({ title: e.target.value })}
|
|
||||||
placeholder="차트 제목을 입력하세요"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* X축 설정 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
X축 (카테고리)
|
|
||||||
<span className="text-red-500 ml-1">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={currentConfig.xAxis || ''}
|
|
||||||
onChange={(e) => updateConfig({ xAxis: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
|
||||||
>
|
|
||||||
<option value="">선택하세요</option>
|
|
||||||
{availableColumns.map((col) => (
|
|
||||||
<option key={col} value={col}>
|
|
||||||
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Y축 설정 (다중 선택 가능) */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
Y축 (값) - 여러 개 선택 가능
|
|
||||||
<span className="text-red-500 ml-1">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="space-y-2 max-h-60 overflow-y-auto border border-gray-300 rounded-lg p-2 bg-white">
|
|
||||||
{availableColumns.map((col) => {
|
|
||||||
const isSelected = Array.isArray(currentConfig.yAxis)
|
|
||||||
? currentConfig.yAxis.includes(col)
|
|
||||||
: currentConfig.yAxis === col;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={col}
|
|
||||||
className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={(e) => {
|
|
||||||
const currentYAxis = Array.isArray(currentConfig.yAxis)
|
|
||||||
? currentConfig.yAxis
|
|
||||||
: currentConfig.yAxis ? [currentConfig.yAxis] : [];
|
|
||||||
|
|
||||||
let newYAxis: string | string[];
|
|
||||||
if (e.target.checked) {
|
|
||||||
newYAxis = [...currentYAxis, col];
|
|
||||||
} else {
|
|
||||||
newYAxis = currentYAxis.filter(c => c !== col);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 단일 값이면 문자열로, 다중 값이면 배열로
|
|
||||||
if (newYAxis.length === 1) {
|
|
||||||
newYAxis = newYAxis[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
updateConfig({ yAxis: newYAxis });
|
|
||||||
}}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<span className="text-sm flex-1">
|
|
||||||
{col}
|
|
||||||
{sampleData[col] && (
|
|
||||||
<span className="text-gray-500 text-xs ml-2">
|
|
||||||
(예: {sampleData[col]})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
💡 팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 집계 함수 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
집계 함수
|
|
||||||
<span className="text-gray-500 text-xs ml-2">(데이터 처리 방식)</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={currentConfig.aggregation || 'sum'}
|
|
||||||
onChange={(e) => updateConfig({ aggregation: e.target.value as any })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
|
||||||
>
|
|
||||||
<option value="sum">합계 (SUM) - 모든 값을 더함</option>
|
|
||||||
<option value="avg">평균 (AVG) - 평균값 계산</option>
|
|
||||||
<option value="count">개수 (COUNT) - 데이터 개수</option>
|
|
||||||
<option value="max">최대값 (MAX) - 가장 큰 값</option>
|
|
||||||
<option value="min">최소값 (MIN) - 가장 작은 값</option>
|
|
||||||
</select>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
💡 집계 함수는 현재 쿼리 결과에 적용되지 않습니다.
|
|
||||||
SQL 쿼리에서 직접 집계하는 것을 권장합니다.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 그룹핑 필드 (선택사항) */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
그룹핑 필드 (선택사항)
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={currentConfig.groupBy || ''}
|
|
||||||
onChange={(e) => updateConfig({ groupBy: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
|
||||||
>
|
|
||||||
<option value="">없음</option>
|
|
||||||
{availableColumns.map((col) => (
|
|
||||||
<option key={col} value={col}>
|
|
||||||
{col}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 차트 색상 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">차트 색상</label>
|
|
||||||
<div className="grid grid-cols-4 gap-2">
|
|
||||||
{[
|
|
||||||
['#3B82F6', '#EF4444', '#10B981', '#F59E0B'], // 기본
|
|
||||||
['#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'], // 밝은
|
|
||||||
['#1F2937', '#374151', '#6B7280', '#9CA3AF'], // 회색
|
|
||||||
['#DC2626', '#EA580C', '#CA8A04', '#65A30D'], // 따뜻한
|
|
||||||
].map((colorSet, setIdx) => (
|
|
||||||
<button
|
|
||||||
key={setIdx}
|
|
||||||
onClick={() => updateConfig({ colors: colorSet })}
|
|
||||||
className={`
|
|
||||||
h-8 rounded border-2 flex
|
|
||||||
${JSON.stringify(currentConfig.colors) === JSON.stringify(colorSet)
|
|
||||||
? 'border-gray-800' : 'border-gray-300'}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{colorSet.map((color, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="flex-1 first:rounded-l last:rounded-r"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 범례 표시 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="showLegend"
|
|
||||||
checked={currentConfig.showLegend !== false}
|
|
||||||
onChange={(e) => updateConfig({ showLegend: e.target.checked })}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<label htmlFor="showLegend" className="text-sm text-gray-700">
|
|
||||||
범례 표시
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 설정 미리보기 */}
|
|
||||||
<div className="p-3 bg-gray-50 rounded-lg">
|
|
||||||
<div className="text-sm font-medium text-gray-700 mb-2">📋 설정 미리보기</div>
|
|
||||||
<div className="text-xs text-gray-600 space-y-1">
|
|
||||||
<div><strong>X축:</strong> {currentConfig.xAxis || '미설정'}</div>
|
|
||||||
<div>
|
|
||||||
<strong>Y축:</strong>{' '}
|
|
||||||
{Array.isArray(currentConfig.yAxis)
|
|
||||||
? `${currentConfig.yAxis.length}개 (${currentConfig.yAxis.join(', ')})`
|
|
||||||
: currentConfig.yAxis || '미설정'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div><strong>집계:</strong> {currentConfig.aggregation || 'sum'}</div>
|
|
||||||
{currentConfig.groupBy && (
|
|
||||||
<div><strong>그룹핑:</strong> {currentConfig.groupBy}</div>
|
|
||||||
)}
|
|
||||||
<div><strong>데이터 행 수:</strong> {queryResult.rows.length}개</div>
|
|
||||||
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 1 && (
|
|
||||||
<div className="text-blue-600 mt-2">
|
|
||||||
✨ 다중 시리즈 차트가 생성됩니다!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 필수 필드 확인 */}
|
|
||||||
{(!currentConfig.xAxis || !currentConfig.yAxis) && (
|
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
||||||
<div className="text-red-800 text-sm">
|
|
||||||
⚠️ X축과 Y축을 모두 설정해야 차트가 표시됩니다.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, { forwardRef, useState, useCallback } from 'react';
|
|
||||||
import { DashboardElement, ElementType, ElementSubtype, DragData } from './types';
|
|
||||||
import { CanvasElement } from './CanvasElement';
|
|
||||||
|
|
||||||
interface DashboardCanvasProps {
|
|
||||||
elements: DashboardElement[];
|
|
||||||
selectedElement: string | null;
|
|
||||||
onCreateElement: (type: ElementType, subtype: ElementSubtype, x: number, y: number) => void;
|
|
||||||
onUpdateElement: (id: string, updates: Partial<DashboardElement>) => void;
|
|
||||||
onRemoveElement: (id: string) => void;
|
|
||||||
onSelectElement: (id: string | null) => void;
|
|
||||||
onConfigureElement?: (element: DashboardElement) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 캔버스 컴포넌트
|
|
||||||
* - 드래그 앤 드롭 영역
|
|
||||||
* - 그리드 배경
|
|
||||||
* - 요소 배치 및 관리
|
|
||||||
*/
|
|
||||||
export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|
||||||
({ elements, selectedElement, onCreateElement, onUpdateElement, onRemoveElement, onSelectElement, onConfigureElement }, ref) => {
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
|
||||||
|
|
||||||
// 드래그 오버 처리
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
|
||||||
setIsDragOver(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 드래그 리브 처리
|
|
||||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
||||||
if (e.currentTarget === e.target) {
|
|
||||||
setIsDragOver(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 드롭 처리
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragOver(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dragData: DragData = JSON.parse(e.dataTransfer.getData('application/json'));
|
|
||||||
|
|
||||||
if (!ref || typeof ref === 'function') return;
|
|
||||||
|
|
||||||
const rect = ref.current?.getBoundingClientRect();
|
|
||||||
if (!rect) return;
|
|
||||||
|
|
||||||
// 캔버스 스크롤을 고려한 정확한 위치 계산
|
|
||||||
const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
|
|
||||||
const y = e.clientY - rect.top + (ref.current?.scrollTop || 0);
|
|
||||||
|
|
||||||
onCreateElement(dragData.type, dragData.subtype, x, y);
|
|
||||||
} catch (error) {
|
|
||||||
// console.error('드롭 데이터 파싱 오류:', error);
|
|
||||||
}
|
|
||||||
}, [ref, onCreateElement]);
|
|
||||||
|
|
||||||
// 캔버스 클릭 시 선택 해제
|
|
||||||
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onSelectElement(null);
|
|
||||||
}
|
|
||||||
}, [onSelectElement]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={`
|
|
||||||
w-full min-h-full relative
|
|
||||||
bg-gray-100
|
|
||||||
bg-grid-pattern
|
|
||||||
${isDragOver ? 'bg-blue-50' : ''}
|
|
||||||
`}
|
|
||||||
style={{
|
|
||||||
backgroundImage: `
|
|
||||||
linear-gradient(rgba(200, 200, 200, 0.3) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(200, 200, 200, 0.3) 1px, transparent 1px)
|
|
||||||
`,
|
|
||||||
backgroundSize: '20px 20px'
|
|
||||||
}}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={handleCanvasClick}
|
|
||||||
>
|
|
||||||
{/* 배치된 요소들 렌더링 */}
|
|
||||||
{elements.map((element) => (
|
|
||||||
<CanvasElement
|
|
||||||
key={element.id}
|
|
||||||
element={element}
|
|
||||||
isSelected={selectedElement === element.id}
|
|
||||||
onUpdate={onUpdateElement}
|
|
||||||
onRemove={onRemoveElement}
|
|
||||||
onSelect={onSelectElement}
|
|
||||||
onConfigure={onConfigureElement}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
DashboardCanvas.displayName = 'DashboardCanvas';
|
|
||||||
|
|
@ -1,297 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback } from 'react';
|
|
||||||
import { DashboardCanvas } from './DashboardCanvas';
|
|
||||||
import { DashboardSidebar } from './DashboardSidebar';
|
|
||||||
import { DashboardToolbar } from './DashboardToolbar';
|
|
||||||
import { ElementConfigModal } from './ElementConfigModal';
|
|
||||||
import { DashboardElement, ElementType, ElementSubtype } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 설계 도구 메인 컴포넌트
|
|
||||||
* - 드래그 앤 드롭으로 차트/위젯 배치
|
|
||||||
* - 요소 이동, 크기 조절, 삭제 기능
|
|
||||||
* - 레이아웃 저장/불러오기 기능
|
|
||||||
*/
|
|
||||||
export default function DashboardDesigner() {
|
|
||||||
const [elements, setElements] = useState<DashboardElement[]>([]);
|
|
||||||
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
|
||||||
const [elementCounter, setElementCounter] = useState(0);
|
|
||||||
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
|
|
||||||
const [dashboardId, setDashboardId] = useState<string | null>(null);
|
|
||||||
const [dashboardTitle, setDashboardTitle] = useState<string>('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드
|
|
||||||
React.useEffect(() => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const loadId = params.get('load');
|
|
||||||
|
|
||||||
if (loadId) {
|
|
||||||
loadDashboard(loadId);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 대시보드 데이터 로드
|
|
||||||
const loadDashboard = async (id: string) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
// console.log('🔄 대시보드 로딩:', id);
|
|
||||||
|
|
||||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
|
||||||
const dashboard = await dashboardApi.getDashboard(id);
|
|
||||||
|
|
||||||
// console.log('✅ 대시보드 로딩 완료:', dashboard);
|
|
||||||
|
|
||||||
// 대시보드 정보 설정
|
|
||||||
setDashboardId(dashboard.id);
|
|
||||||
setDashboardTitle(dashboard.title);
|
|
||||||
|
|
||||||
// 요소들 설정
|
|
||||||
if (dashboard.elements && dashboard.elements.length > 0) {
|
|
||||||
setElements(dashboard.elements);
|
|
||||||
|
|
||||||
// elementCounter를 가장 큰 ID 번호로 설정
|
|
||||||
const maxId = dashboard.elements.reduce((max, el) => {
|
|
||||||
const match = el.id.match(/element-(\d+)/);
|
|
||||||
if (match) {
|
|
||||||
const num = parseInt(match[1]);
|
|
||||||
return num > max ? num : max;
|
|
||||||
}
|
|
||||||
return max;
|
|
||||||
}, 0);
|
|
||||||
setElementCounter(maxId);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
// console.error('❌ 대시보드 로딩 오류:', error);
|
|
||||||
alert('대시보드를 불러오는 중 오류가 발생했습니다.\n\n' + (error instanceof Error ? error.message : '알 수 없는 오류'));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 새로운 요소 생성
|
|
||||||
const createElement = useCallback((
|
|
||||||
type: ElementType,
|
|
||||||
subtype: ElementSubtype,
|
|
||||||
x: number,
|
|
||||||
y: number
|
|
||||||
) => {
|
|
||||||
const newElement: DashboardElement = {
|
|
||||||
id: `element-${elementCounter + 1}`,
|
|
||||||
type,
|
|
||||||
subtype,
|
|
||||||
position: { x, y },
|
|
||||||
size: { width: 250, height: 200 },
|
|
||||||
title: getElementTitle(type, subtype),
|
|
||||||
content: getElementContent(type, subtype)
|
|
||||||
};
|
|
||||||
|
|
||||||
setElements(prev => [...prev, newElement]);
|
|
||||||
setElementCounter(prev => prev + 1);
|
|
||||||
setSelectedElement(newElement.id);
|
|
||||||
}, [elementCounter]);
|
|
||||||
|
|
||||||
// 요소 업데이트
|
|
||||||
const updateElement = useCallback((id: string, updates: Partial<DashboardElement>) => {
|
|
||||||
setElements(prev => prev.map(el =>
|
|
||||||
el.id === id ? { ...el, ...updates } : el
|
|
||||||
));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 요소 삭제
|
|
||||||
const removeElement = useCallback((id: string) => {
|
|
||||||
setElements(prev => prev.filter(el => el.id !== id));
|
|
||||||
if (selectedElement === id) {
|
|
||||||
setSelectedElement(null);
|
|
||||||
}
|
|
||||||
}, [selectedElement]);
|
|
||||||
|
|
||||||
// 전체 삭제
|
|
||||||
const clearCanvas = useCallback(() => {
|
|
||||||
if (window.confirm('모든 요소를 삭제하시겠습니까?')) {
|
|
||||||
setElements([]);
|
|
||||||
setSelectedElement(null);
|
|
||||||
setElementCounter(0);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 요소 설정 모달 열기
|
|
||||||
const openConfigModal = useCallback((element: DashboardElement) => {
|
|
||||||
setConfigModalElement(element);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 요소 설정 모달 닫기
|
|
||||||
const closeConfigModal = useCallback(() => {
|
|
||||||
setConfigModalElement(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 요소 설정 저장
|
|
||||||
const saveElementConfig = useCallback((updatedElement: DashboardElement) => {
|
|
||||||
updateElement(updatedElement.id, updatedElement);
|
|
||||||
}, [updateElement]);
|
|
||||||
|
|
||||||
// 레이아웃 저장
|
|
||||||
const saveLayout = useCallback(async () => {
|
|
||||||
if (elements.length === 0) {
|
|
||||||
alert('저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 실제 API 호출
|
|
||||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
|
||||||
|
|
||||||
const elementsData = elements.map(el => ({
|
|
||||||
id: el.id,
|
|
||||||
type: el.type,
|
|
||||||
subtype: el.subtype,
|
|
||||||
position: el.position,
|
|
||||||
size: el.size,
|
|
||||||
title: el.title,
|
|
||||||
content: el.content,
|
|
||||||
dataSource: el.dataSource,
|
|
||||||
chartConfig: el.chartConfig
|
|
||||||
}));
|
|
||||||
|
|
||||||
let savedDashboard;
|
|
||||||
|
|
||||||
if (dashboardId) {
|
|
||||||
// 기존 대시보드 업데이트
|
|
||||||
// console.log('🔄 대시보드 업데이트:', dashboardId);
|
|
||||||
savedDashboard = await dashboardApi.updateDashboard(dashboardId, {
|
|
||||||
elements: elementsData
|
|
||||||
});
|
|
||||||
|
|
||||||
alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`);
|
|
||||||
|
|
||||||
// 뷰어 페이지로 이동
|
|
||||||
window.location.href = `/dashboard/${savedDashboard.id}`;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// 새 대시보드 생성
|
|
||||||
const title = prompt('대시보드 제목을 입력하세요:', '새 대시보드');
|
|
||||||
if (!title) return;
|
|
||||||
|
|
||||||
const description = prompt('대시보드 설명을 입력하세요 (선택사항):', '');
|
|
||||||
|
|
||||||
const dashboardData = {
|
|
||||||
title,
|
|
||||||
description: description || undefined,
|
|
||||||
isPublic: false,
|
|
||||||
elements: elementsData
|
|
||||||
};
|
|
||||||
|
|
||||||
savedDashboard = await dashboardApi.createDashboard(dashboardData);
|
|
||||||
|
|
||||||
// console.log('✅ 대시보드 생성 완료:', savedDashboard);
|
|
||||||
|
|
||||||
const viewDashboard = confirm(`대시보드 "${title}"이 저장되었습니다!\n\n지금 확인해보시겠습니까?`);
|
|
||||||
if (viewDashboard) {
|
|
||||||
window.location.href = `/dashboard/${savedDashboard.id}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
// console.error('❌ 저장 오류:', error);
|
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류';
|
|
||||||
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`);
|
|
||||||
}
|
|
||||||
}, [elements, dashboardId]);
|
|
||||||
|
|
||||||
// 로딩 중이면 로딩 화면 표시
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
|
||||||
<div className="text-lg font-medium text-gray-700">대시보드 로딩 중...</div>
|
|
||||||
<div className="text-sm text-gray-500 mt-1">잠시만 기다려주세요</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full bg-gray-50">
|
|
||||||
{/* 캔버스 영역 */}
|
|
||||||
<div className="flex-1 relative overflow-auto border-r-2 border-gray-300">
|
|
||||||
{/* 편집 중인 대시보드 표시 */}
|
|
||||||
{dashboardTitle && (
|
|
||||||
<div className="absolute top-2 left-2 z-10 bg-blue-500 text-white px-3 py-1 rounded-lg text-sm font-medium shadow-lg">
|
|
||||||
📝 편집 중: {dashboardTitle}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DashboardToolbar
|
|
||||||
onClearCanvas={clearCanvas}
|
|
||||||
onSaveLayout={saveLayout}
|
|
||||||
/>
|
|
||||||
<DashboardCanvas
|
|
||||||
ref={canvasRef}
|
|
||||||
elements={elements}
|
|
||||||
selectedElement={selectedElement}
|
|
||||||
onCreateElement={createElement}
|
|
||||||
onUpdateElement={updateElement}
|
|
||||||
onRemoveElement={removeElement}
|
|
||||||
onSelectElement={setSelectedElement}
|
|
||||||
onConfigureElement={openConfigModal}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 사이드바 */}
|
|
||||||
<DashboardSidebar />
|
|
||||||
|
|
||||||
{/* 요소 설정 모달 */}
|
|
||||||
{configModalElement && (
|
|
||||||
<ElementConfigModal
|
|
||||||
element={configModalElement}
|
|
||||||
isOpen={true}
|
|
||||||
onClose={closeConfigModal}
|
|
||||||
onSave={saveElementConfig}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 요소 제목 생성 헬퍼 함수
|
|
||||||
function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
|
||||||
if (type === 'chart') {
|
|
||||||
switch (subtype) {
|
|
||||||
case 'bar': return '📊 바 차트';
|
|
||||||
case 'pie': return '🥧 원형 차트';
|
|
||||||
case 'line': return '📈 꺾은선 차트';
|
|
||||||
default: return '📊 차트';
|
|
||||||
}
|
|
||||||
} else if (type === 'widget') {
|
|
||||||
switch (subtype) {
|
|
||||||
case 'exchange': return '💱 환율 위젯';
|
|
||||||
case 'weather': return '☁️ 날씨 위젯';
|
|
||||||
default: return '🔧 위젯';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '요소';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 요소 내용 생성 헬퍼 함수
|
|
||||||
function getElementContent(type: ElementType, subtype: ElementSubtype): string {
|
|
||||||
if (type === 'chart') {
|
|
||||||
switch (subtype) {
|
|
||||||
case 'bar': return '바 차트가 여기에 표시됩니다';
|
|
||||||
case 'pie': return '원형 차트가 여기에 표시됩니다';
|
|
||||||
case 'line': return '꺾은선 차트가 여기에 표시됩니다';
|
|
||||||
default: return '차트가 여기에 표시됩니다';
|
|
||||||
}
|
|
||||||
} else if (type === 'widget') {
|
|
||||||
switch (subtype) {
|
|
||||||
case 'exchange': return 'USD: ₩1,320\nJPY: ₩900\nEUR: ₩1,450';
|
|
||||||
case 'weather': return '서울\n23°C\n구름 많음';
|
|
||||||
default: return '위젯 내용이 여기에 표시됩니다';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '내용이 여기에 표시됩니다';
|
|
||||||
}
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { DragData, ElementType, ElementSubtype } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 사이드바 컴포넌트
|
|
||||||
* - 드래그 가능한 차트/위젯 목록
|
|
||||||
* - 카테고리별 구분
|
|
||||||
*/
|
|
||||||
export function DashboardSidebar() {
|
|
||||||
// 드래그 시작 처리
|
|
||||||
const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => {
|
|
||||||
const dragData: DragData = { type, subtype };
|
|
||||||
e.dataTransfer.setData('application/json', JSON.stringify(dragData));
|
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-80 bg-white border-l border-gray-200 overflow-y-auto p-5">
|
|
||||||
{/* 차트 섹션 */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h3 className="text-gray-800 mb-4 pb-3 border-b-2 border-green-500 font-semibold text-lg">
|
|
||||||
📊 차트 종류
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<DraggableItem
|
|
||||||
icon="📊"
|
|
||||||
title="바 차트"
|
|
||||||
type="chart"
|
|
||||||
subtype="bar"
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
className="border-l-4 border-blue-500"
|
|
||||||
/>
|
|
||||||
<DraggableItem
|
|
||||||
icon="📚"
|
|
||||||
title="누적 바 차트"
|
|
||||||
type="chart"
|
|
||||||
subtype="stacked-bar"
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
className="border-l-4 border-blue-600"
|
|
||||||
/>
|
|
||||||
<DraggableItem
|
|
||||||
icon="📈"
|
|
||||||
title="꺾은선 차트"
|
|
||||||
type="chart"
|
|
||||||
subtype="line"
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
className="border-l-4 border-green-500"
|
|
||||||
/>
|
|
||||||
<DraggableItem
|
|
||||||
icon="📉"
|
|
||||||
title="영역 차트"
|
|
||||||
type="chart"
|
|
||||||
subtype="area"
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
className="border-l-4 border-green-600"
|
|
||||||
/>
|
|
||||||
<DraggableItem
|
|
||||||
icon="🥧"
|
|
||||||
title="원형 차트"
|
|
||||||
type="chart"
|
|
||||||
subtype="pie"
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
className="border-l-4 border-purple-500"
|
|
||||||
/>
|
|
||||||
<DraggableItem
|
|
||||||
icon="🍩"
|
|
||||||
title="도넛 차트"
|
|
||||||
type="chart"
|
|
||||||
subtype="donut"
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
className="border-l-4 border-purple-600"
|
|
||||||
/>
|
|
||||||
<DraggableItem
|
|
||||||
icon="📊📈"
|
|
||||||
title="콤보 차트"
|
|
||||||
type="chart"
|
|
||||||
subtype="combo"
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
className="border-l-4 border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 위젯 섹션 */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h3 className="text-gray-800 mb-4 pb-3 border-b-2 border-green-500 font-semibold text-lg">
|
|
||||||
🔧 위젯 종류
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<DraggableItem
|
|
||||||
icon="💱"
|
|
||||||
title="환율 위젯"
|
|
||||||
type="widget"
|
|
||||||
subtype="exchange"
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
className="border-l-4 border-orange-500"
|
|
||||||
/>
|
|
||||||
<DraggableItem
|
|
||||||
icon="☁️"
|
|
||||||
title="날씨 위젯"
|
|
||||||
type="widget"
|
|
||||||
subtype="weather"
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
className="border-l-4 border-orange-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DraggableItemProps {
|
|
||||||
icon: string;
|
|
||||||
title: string;
|
|
||||||
type: ElementType;
|
|
||||||
subtype: ElementSubtype;
|
|
||||||
className?: string;
|
|
||||||
onDragStart: (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 드래그 가능한 아이템 컴포넌트
|
|
||||||
*/
|
|
||||||
function DraggableItem({ icon, title, type, subtype, className = '', onDragStart }: DraggableItemProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
draggable
|
|
||||||
className={`
|
|
||||||
p-4 bg-white border-2 border-gray-200 rounded-lg
|
|
||||||
cursor-move transition-all duration-200
|
|
||||||
hover:bg-gray-50 hover:border-green-500 hover:translate-x-1
|
|
||||||
text-center text-sm font-medium
|
|
||||||
${className}
|
|
||||||
`}
|
|
||||||
onDragStart={(e) => onDragStart(e, type, subtype)}
|
|
||||||
>
|
|
||||||
<span className="text-lg mr-2">{icon}</span>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface DashboardToolbarProps {
|
|
||||||
onClearCanvas: () => void;
|
|
||||||
onSaveLayout: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 툴바 컴포넌트
|
|
||||||
* - 전체 삭제, 레이아웃 저장 등 주요 액션 버튼
|
|
||||||
*/
|
|
||||||
export function DashboardToolbar({ onClearCanvas, onSaveLayout }: DashboardToolbarProps) {
|
|
||||||
return (
|
|
||||||
<div className="absolute top-5 left-5 bg-white p-3 rounded-lg shadow-lg z-50 flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={onClearCanvas}
|
|
||||||
className="
|
|
||||||
px-4 py-2 border border-gray-300 bg-white rounded-md
|
|
||||||
text-sm font-medium text-gray-700
|
|
||||||
hover:bg-gray-50 hover:border-gray-400
|
|
||||||
transition-colors duration-200
|
|
||||||
"
|
|
||||||
>
|
|
||||||
🗑️ 전체 삭제
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onSaveLayout}
|
|
||||||
className="
|
|
||||||
px-4 py-2 border border-gray-300 bg-white rounded-md
|
|
||||||
text-sm font-medium text-gray-700
|
|
||||||
hover:bg-gray-50 hover:border-gray-400
|
|
||||||
transition-colors duration-200
|
|
||||||
"
|
|
||||||
>
|
|
||||||
💾 레이아웃 저장
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
|
||||||
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from './types';
|
|
||||||
import { QueryEditor } from './QueryEditor';
|
|
||||||
import { ChartConfigPanel } from './ChartConfigPanel';
|
|
||||||
|
|
||||||
interface ElementConfigModalProps {
|
|
||||||
element: DashboardElement;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (element: DashboardElement) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 요소 설정 모달 컴포넌트
|
|
||||||
* - 차트/위젯 데이터 소스 설정
|
|
||||||
* - 쿼리 에디터 통합
|
|
||||||
* - 차트 설정 패널 통합
|
|
||||||
*/
|
|
||||||
export function ElementConfigModal({ element, isOpen, onClose, onSave }: ElementConfigModalProps) {
|
|
||||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
|
||||||
element.dataSource || { type: 'database', refreshInterval: 30000 }
|
|
||||||
);
|
|
||||||
const [chartConfig, setChartConfig] = useState<ChartConfig>(
|
|
||||||
element.chartConfig || {}
|
|
||||||
);
|
|
||||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState<'query' | 'chart'>('query');
|
|
||||||
|
|
||||||
// 데이터 소스 변경 처리
|
|
||||||
const handleDataSourceChange = useCallback((newDataSource: ChartDataSource) => {
|
|
||||||
setDataSource(newDataSource);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 차트 설정 변경 처리
|
|
||||||
const handleChartConfigChange = useCallback((newConfig: ChartConfig) => {
|
|
||||||
setChartConfig(newConfig);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 쿼리 테스트 결과 처리
|
|
||||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
|
||||||
setQueryResult(result);
|
|
||||||
// 쿼리 결과가 나오면 자동으로 차트 설정 탭으로 이동
|
|
||||||
if (result.rows.length > 0) {
|
|
||||||
setActiveTab('chart');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 저장 처리
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
const updatedElement: DashboardElement = {
|
|
||||||
...element,
|
|
||||||
dataSource,
|
|
||||||
chartConfig,
|
|
||||||
};
|
|
||||||
onSave(updatedElement);
|
|
||||||
onClose();
|
|
||||||
}, [element, dataSource, chartConfig, onSave, onClose]);
|
|
||||||
|
|
||||||
// 모달이 열려있지 않으면 렌더링하지 않음
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl h-[80vh] flex flex-col">
|
|
||||||
{/* 모달 헤더 */}
|
|
||||||
<div className="flex justify-between items-center p-6 border-b border-gray-200">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-800">
|
|
||||||
{element.title} 설정
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
데이터 소스와 차트 설정을 구성하세요
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-gray-400 hover:text-gray-600 text-2xl"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 탭 네비게이션 */}
|
|
||||||
<div className="flex border-b border-gray-200">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('query')}
|
|
||||||
className={`
|
|
||||||
px-6 py-3 text-sm font-medium border-b-2 transition-colors
|
|
||||||
${activeTab === 'query'
|
|
||||||
? 'border-blue-500 text-blue-600 bg-blue-50'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
📝 쿼리 & 데이터
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('chart')}
|
|
||||||
className={`
|
|
||||||
px-6 py-3 text-sm font-medium border-b-2 transition-colors
|
|
||||||
${activeTab === 'chart'
|
|
||||||
? 'border-blue-500 text-blue-600 bg-blue-50'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
📊 차트 설정
|
|
||||||
{queryResult && (
|
|
||||||
<span className="ml-2 px-2 py-0.5 bg-green-100 text-green-800 text-xs rounded-full">
|
|
||||||
{queryResult.rows.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 탭 내용 */}
|
|
||||||
<div className="flex-1 overflow-auto p-6">
|
|
||||||
{activeTab === 'query' && (
|
|
||||||
<QueryEditor
|
|
||||||
dataSource={dataSource}
|
|
||||||
onDataSourceChange={handleDataSourceChange}
|
|
||||||
onQueryTest={handleQueryTest}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'chart' && (
|
|
||||||
<ChartConfigPanel
|
|
||||||
config={chartConfig}
|
|
||||||
queryResult={queryResult}
|
|
||||||
onConfigChange={handleChartConfigChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 모달 푸터 */}
|
|
||||||
<div className="flex justify-between items-center p-6 border-t border-gray-200">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{dataSource.query && (
|
|
||||||
<>
|
|
||||||
💾 쿼리: {dataSource.query.length > 50
|
|
||||||
? `${dataSource.query.substring(0, 50)}...`
|
|
||||||
: dataSource.query}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!dataSource.query || (!chartConfig.xAxis || !chartConfig.yAxis)}
|
|
||||||
className="
|
|
||||||
px-4 py-2 bg-blue-500 text-white rounded-lg
|
|
||||||
hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed
|
|
||||||
"
|
|
||||||
>
|
|
||||||
저장
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,489 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
|
||||||
import { ChartDataSource, QueryResult } from './types';
|
|
||||||
|
|
||||||
interface QueryEditorProps {
|
|
||||||
dataSource?: ChartDataSource;
|
|
||||||
onDataSourceChange: (dataSource: ChartDataSource) => void;
|
|
||||||
onQueryTest?: (result: QueryResult) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SQL 쿼리 에디터 컴포넌트
|
|
||||||
* - SQL 쿼리 작성 및 편집
|
|
||||||
* - 쿼리 실행 및 결과 미리보기
|
|
||||||
* - 데이터 소스 설정
|
|
||||||
*/
|
|
||||||
export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: QueryEditorProps) {
|
|
||||||
const [query, setQuery] = useState(dataSource?.query || '');
|
|
||||||
const [isExecuting, setIsExecuting] = useState(false);
|
|
||||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 쿼리 실행
|
|
||||||
const executeQuery = useCallback(async () => {
|
|
||||||
if (!query.trim()) {
|
|
||||||
setError('쿼리를 입력해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsExecuting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 실제 API 호출
|
|
||||||
const response = await fetch('http://localhost:8080/api/dashboards/execute-query', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token') || 'test-token'}` // JWT 토큰 사용
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ query: query.trim() })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.message || '쿼리 실행에 실패했습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiResult = await response.json();
|
|
||||||
|
|
||||||
if (!apiResult.success) {
|
|
||||||
throw new Error(apiResult.message || '쿼리 실행에 실패했습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// API 결과를 QueryResult 형식으로 변환
|
|
||||||
const result: QueryResult = {
|
|
||||||
columns: apiResult.data.columns,
|
|
||||||
rows: apiResult.data.rows,
|
|
||||||
totalRows: apiResult.data.rowCount,
|
|
||||||
executionTime: 0 // API에서 실행 시간을 제공하지 않으므로 0으로 설정
|
|
||||||
};
|
|
||||||
|
|
||||||
setQueryResult(result);
|
|
||||||
onQueryTest?.(result);
|
|
||||||
|
|
||||||
// 데이터 소스 업데이트
|
|
||||||
onDataSourceChange({
|
|
||||||
type: 'database',
|
|
||||||
query: query.trim(),
|
|
||||||
refreshInterval: dataSource?.refreshInterval || 30000,
|
|
||||||
lastExecuted: new Date().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : '쿼리 실행 중 오류가 발생했습니다.';
|
|
||||||
setError(errorMessage);
|
|
||||||
// console.error('Query execution error:', err);
|
|
||||||
} finally {
|
|
||||||
setIsExecuting(false);
|
|
||||||
}
|
|
||||||
}, [query, dataSource?.refreshInterval, onDataSourceChange, onQueryTest]);
|
|
||||||
|
|
||||||
// 샘플 쿼리 삽입
|
|
||||||
const insertSampleQuery = useCallback((sampleType: string) => {
|
|
||||||
const samples = {
|
|
||||||
comparison: `-- 제품별 월별 매출 비교 (다중 시리즈)
|
|
||||||
-- 갤럭시(Galaxy) vs 아이폰(iPhone) 매출 비교
|
|
||||||
SELECT
|
|
||||||
DATE_TRUNC('month', order_date) as month,
|
|
||||||
SUM(CASE WHEN product_category = '갤럭시' THEN amount ELSE 0 END) as galaxy_sales,
|
|
||||||
SUM(CASE WHEN product_category = '아이폰' THEN amount ELSE 0 END) as iphone_sales,
|
|
||||||
SUM(CASE WHEN product_category = '기타' THEN amount ELSE 0 END) as other_sales
|
|
||||||
FROM orders
|
|
||||||
WHERE order_date >= CURRENT_DATE - INTERVAL '12 months'
|
|
||||||
GROUP BY DATE_TRUNC('month', order_date)
|
|
||||||
ORDER BY month;`,
|
|
||||||
|
|
||||||
sales: `-- 월별 매출 데이터
|
|
||||||
SELECT
|
|
||||||
DATE_TRUNC('month', order_date) as month,
|
|
||||||
SUM(total_amount) as sales,
|
|
||||||
COUNT(*) as order_count
|
|
||||||
FROM orders
|
|
||||||
WHERE order_date >= CURRENT_DATE - INTERVAL '12 months'
|
|
||||||
GROUP BY DATE_TRUNC('month', order_date)
|
|
||||||
ORDER BY month;`,
|
|
||||||
|
|
||||||
users: `-- 사용자 가입 추이
|
|
||||||
SELECT
|
|
||||||
DATE_TRUNC('week', created_at) as week,
|
|
||||||
COUNT(*) as new_users
|
|
||||||
FROM users
|
|
||||||
WHERE created_at >= CURRENT_DATE - INTERVAL '3 months'
|
|
||||||
GROUP BY DATE_TRUNC('week', created_at)
|
|
||||||
ORDER BY week;`,
|
|
||||||
|
|
||||||
products: `-- 상품별 판매량
|
|
||||||
SELECT
|
|
||||||
product_name,
|
|
||||||
SUM(quantity) as total_sold,
|
|
||||||
SUM(quantity * price) as revenue
|
|
||||||
FROM order_items oi
|
|
||||||
JOIN products p ON oi.product_id = p.id
|
|
||||||
WHERE oi.created_at >= CURRENT_DATE - INTERVAL '1 month'
|
|
||||||
GROUP BY product_name
|
|
||||||
ORDER BY total_sold DESC
|
|
||||||
LIMIT 10;`,
|
|
||||||
|
|
||||||
regional: `-- 지역별 매출 비교
|
|
||||||
SELECT
|
|
||||||
region as 지역,
|
|
||||||
SUM(CASE WHEN quarter = 'Q1' THEN sales ELSE 0 END) as Q1,
|
|
||||||
SUM(CASE WHEN quarter = 'Q2' THEN sales ELSE 0 END) as Q2,
|
|
||||||
SUM(CASE WHEN quarter = 'Q3' THEN sales ELSE 0 END) as Q3,
|
|
||||||
SUM(CASE WHEN quarter = 'Q4' THEN sales ELSE 0 END) as Q4
|
|
||||||
FROM regional_sales
|
|
||||||
WHERE year = EXTRACT(YEAR FROM CURRENT_DATE)
|
|
||||||
GROUP BY region
|
|
||||||
ORDER BY Q4 DESC;`
|
|
||||||
};
|
|
||||||
|
|
||||||
setQuery(samples[sampleType as keyof typeof samples] || '');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 쿼리 에디터 헤더 */}
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h4 className="text-lg font-semibold text-gray-800">📝 SQL 쿼리 에디터</h4>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={executeQuery}
|
|
||||||
disabled={isExecuting || !query.trim()}
|
|
||||||
className="
|
|
||||||
px-3 py-1 bg-blue-500 text-white rounded text-sm
|
|
||||||
hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed
|
|
||||||
flex items-center gap-1
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{isExecuting ? (
|
|
||||||
<>
|
|
||||||
<div className="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin" />
|
|
||||||
실행 중...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>▶ 실행</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 샘플 쿼리 버튼들 */}
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
<span className="text-sm text-gray-600">샘플 쿼리:</span>
|
|
||||||
<button
|
|
||||||
onClick={() => insertSampleQuery('comparison')}
|
|
||||||
className="px-2 py-1 text-xs bg-blue-100 hover:bg-blue-200 rounded font-medium"
|
|
||||||
>
|
|
||||||
🔥 제품 비교
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => insertSampleQuery('regional')}
|
|
||||||
className="px-2 py-1 text-xs bg-green-100 hover:bg-green-200 rounded font-medium"
|
|
||||||
>
|
|
||||||
🌍 지역별 비교
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => insertSampleQuery('sales')}
|
|
||||||
className="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded"
|
|
||||||
>
|
|
||||||
매출 데이터
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => insertSampleQuery('users')}
|
|
||||||
className="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded"
|
|
||||||
>
|
|
||||||
사용자 추이
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => insertSampleQuery('products')}
|
|
||||||
className="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded"
|
|
||||||
>
|
|
||||||
상품 판매량
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SQL 쿼리 입력 영역 */}
|
|
||||||
<div className="relative">
|
|
||||||
<textarea
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder="SELECT * FROM your_table WHERE condition = 'value';"
|
|
||||||
className="
|
|
||||||
w-full h-40 p-3 border border-gray-300 rounded-lg
|
|
||||||
font-mono text-sm resize-none
|
|
||||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-2 right-2 text-xs text-gray-400">
|
|
||||||
Ctrl+Enter로 실행
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 새로고침 간격 설정 */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<label className="text-sm text-gray-600">자동 새로고침:</label>
|
|
||||||
<select
|
|
||||||
value={dataSource?.refreshInterval || 30000}
|
|
||||||
onChange={(e) => onDataSourceChange({
|
|
||||||
...dataSource,
|
|
||||||
type: 'database',
|
|
||||||
query,
|
|
||||||
refreshInterval: parseInt(e.target.value)
|
|
||||||
})}
|
|
||||||
className="px-2 py-1 border border-gray-300 rounded text-sm"
|
|
||||||
>
|
|
||||||
<option value={0}>수동</option>
|
|
||||||
<option value={10000}>10초</option>
|
|
||||||
<option value={30000}>30초</option>
|
|
||||||
<option value={60000}>1분</option>
|
|
||||||
<option value={300000}>5분</option>
|
|
||||||
<option value={600000}>10분</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 오류 메시지 */}
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
||||||
<div className="text-red-800 text-sm font-medium">❌ 오류</div>
|
|
||||||
<div className="text-red-700 text-sm mt-1">{error}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 쿼리 결과 미리보기 */}
|
|
||||||
{queryResult && (
|
|
||||||
<div className="border border-gray-200 rounded-lg">
|
|
||||||
<div className="bg-gray-50 px-3 py-2 border-b border-gray-200">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm font-medium text-gray-700">
|
|
||||||
📊 쿼리 결과 ({queryResult.rows.length}행)
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
실행 시간: {queryResult.executionTime}ms
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 max-h-60 overflow-auto">
|
|
||||||
{queryResult.rows.length > 0 ? (
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-gray-200">
|
|
||||||
{queryResult.columns.map((col, idx) => (
|
|
||||||
<th key={idx} className="text-left py-1 px-2 font-medium text-gray-700">
|
|
||||||
{col}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{queryResult.rows.slice(0, 10).map((row, idx) => (
|
|
||||||
<tr key={idx} className="border-b border-gray-100">
|
|
||||||
{queryResult.columns.map((col, colIdx) => (
|
|
||||||
<td key={colIdx} className="py-1 px-2 text-gray-600">
|
|
||||||
{String(row[col] ?? '')}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
) : (
|
|
||||||
<div className="text-center text-gray-500 py-4">
|
|
||||||
결과가 없습니다.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{queryResult.rows.length > 10 && (
|
|
||||||
<div className="text-center text-xs text-gray-500 mt-2">
|
|
||||||
... 및 {queryResult.rows.length - 10}개 더 (미리보기는 10행까지만 표시)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 키보드 단축키 안내 */}
|
|
||||||
<div className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
|
|
||||||
💡 <strong>단축키:</strong> Ctrl+Enter (쿼리 실행), Ctrl+/ (주석 토글)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ctrl+Enter로 쿼리 실행
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.ctrlKey && e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
executeQuery();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [executeQuery]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 샘플 쿼리 결과 생성 함수
|
|
||||||
*/
|
|
||||||
function generateSampleQueryResult(query: string): QueryResult {
|
|
||||||
// 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
|
|
||||||
const queryLower = query.toLowerCase();
|
|
||||||
|
|
||||||
// 디버깅용 로그
|
|
||||||
// console.log('generateSampleQueryResult called with query:', query.substring(0, 100));
|
|
||||||
|
|
||||||
// 가장 구체적인 조건부터 먼저 체크 (순서 중요!)
|
|
||||||
const isComparison = queryLower.includes('galaxy') || queryLower.includes('갤럭시') || queryLower.includes('아이폰') || queryLower.includes('iphone');
|
|
||||||
const isRegional = queryLower.includes('region') || queryLower.includes('지역');
|
|
||||||
const isMonthly = queryLower.includes('month');
|
|
||||||
const isSales = queryLower.includes('sales') || queryLower.includes('매출');
|
|
||||||
const isUsers = queryLower.includes('users') || queryLower.includes('사용자');
|
|
||||||
const isProducts = queryLower.includes('product') || queryLower.includes('상품');
|
|
||||||
const isWeekly = queryLower.includes('week');
|
|
||||||
|
|
||||||
// console.log('Sample data type detection:', {
|
|
||||||
// isComparison,
|
|
||||||
// isRegional,
|
|
||||||
// isWeekly,
|
|
||||||
// isProducts,
|
|
||||||
// isMonthly,
|
|
||||||
// isSales,
|
|
||||||
// isUsers,
|
|
||||||
// querySnippet: query.substring(0, 200)
|
|
||||||
// });
|
|
||||||
|
|
||||||
let columns: string[];
|
|
||||||
let rows: Record<string, any>[];
|
|
||||||
|
|
||||||
// 더 구체적인 조건부터 먼저 체크 (순서 중요!)
|
|
||||||
if (isComparison) {
|
|
||||||
// console.log('✅ Using COMPARISON data');
|
|
||||||
// 제품 비교 데이터 (다중 시리즈)
|
|
||||||
columns = ['month', 'galaxy_sales', 'iphone_sales', 'other_sales'];
|
|
||||||
rows = [
|
|
||||||
{ month: '2024-01', galaxy_sales: 450000, iphone_sales: 620000, other_sales: 130000 },
|
|
||||||
{ month: '2024-02', galaxy_sales: 520000, iphone_sales: 680000, other_sales: 150000 },
|
|
||||||
{ month: '2024-03', galaxy_sales: 480000, iphone_sales: 590000, other_sales: 110000 },
|
|
||||||
{ month: '2024-04', galaxy_sales: 610000, iphone_sales: 650000, other_sales: 160000 },
|
|
||||||
{ month: '2024-05', galaxy_sales: 720000, iphone_sales: 780000, other_sales: 180000 },
|
|
||||||
{ month: '2024-06', galaxy_sales: 680000, iphone_sales: 690000, other_sales: 170000 },
|
|
||||||
{ month: '2024-07', galaxy_sales: 750000, iphone_sales: 800000, other_sales: 170000 },
|
|
||||||
{ month: '2024-08', galaxy_sales: 690000, iphone_sales: 720000, other_sales: 170000 },
|
|
||||||
{ month: '2024-09', galaxy_sales: 730000, iphone_sales: 750000, other_sales: 170000 },
|
|
||||||
{ month: '2024-10', galaxy_sales: 800000, iphone_sales: 810000, other_sales: 170000 },
|
|
||||||
{ month: '2024-11', galaxy_sales: 870000, iphone_sales: 880000, other_sales: 170000 },
|
|
||||||
{ month: '2024-12', galaxy_sales: 950000, iphone_sales: 990000, other_sales: 160000 },
|
|
||||||
];
|
|
||||||
// COMPARISON 데이터를 반환하고 함수 종료
|
|
||||||
// console.log('COMPARISON data generated:', {
|
|
||||||
// columns,
|
|
||||||
// rowCount: rows.length,
|
|
||||||
// sampleRow: rows[0],
|
|
||||||
// allRows: rows,
|
|
||||||
// fieldTypes: {
|
|
||||||
// month: typeof rows[0].month,
|
|
||||||
// galaxy_sales: typeof rows[0].galaxy_sales,
|
|
||||||
// iphone_sales: typeof rows[0].iphone_sales,
|
|
||||||
// other_sales: typeof rows[0].other_sales
|
|
||||||
// },
|
|
||||||
// firstFewRows: rows.slice(0, 3),
|
|
||||||
// lastFewRows: rows.slice(-3)
|
|
||||||
// });
|
|
||||||
return {
|
|
||||||
columns,
|
|
||||||
rows,
|
|
||||||
totalRows: rows.length,
|
|
||||||
executionTime: Math.floor(Math.random() * 200) + 100,
|
|
||||||
};
|
|
||||||
} else if (isRegional) {
|
|
||||||
// console.log('✅ Using REGIONAL data');
|
|
||||||
// 지역별 분기별 매출
|
|
||||||
columns = ['지역', 'Q1', 'Q2', 'Q3', 'Q4'];
|
|
||||||
rows = [
|
|
||||||
{ 지역: '서울', Q1: 1200000, Q2: 1350000, Q3: 1420000, Q4: 1580000 },
|
|
||||||
{ 지역: '경기', Q1: 980000, Q2: 1120000, Q3: 1180000, Q4: 1290000 },
|
|
||||||
{ 지역: '부산', Q1: 650000, Q2: 720000, Q3: 780000, Q4: 850000 },
|
|
||||||
{ 지역: '대구', Q1: 450000, Q2: 490000, Q3: 520000, Q4: 580000 },
|
|
||||||
{ 지역: '인천', Q1: 520000, Q2: 580000, Q3: 620000, Q4: 690000 },
|
|
||||||
{ 지역: '광주', Q1: 380000, Q2: 420000, Q3: 450000, Q4: 490000 },
|
|
||||||
{ 지역: '대전', Q1: 410000, Q2: 460000, Q3: 490000, Q4: 530000 },
|
|
||||||
];
|
|
||||||
} else if (isWeekly && isUsers) {
|
|
||||||
// console.log('✅ Using USERS data');
|
|
||||||
// 사용자 가입 추이
|
|
||||||
columns = ['week', 'new_users'];
|
|
||||||
rows = [
|
|
||||||
{ week: '2024-W10', new_users: 23 },
|
|
||||||
{ week: '2024-W11', new_users: 31 },
|
|
||||||
{ week: '2024-W12', new_users: 28 },
|
|
||||||
{ week: '2024-W13', new_users: 35 },
|
|
||||||
{ week: '2024-W14', new_users: 42 },
|
|
||||||
{ week: '2024-W15', new_users: 38 },
|
|
||||||
{ week: '2024-W16', new_users: 45 },
|
|
||||||
{ week: '2024-W17', new_users: 52 },
|
|
||||||
{ week: '2024-W18', new_users: 48 },
|
|
||||||
{ week: '2024-W19', new_users: 55 },
|
|
||||||
{ week: '2024-W20', new_users: 61 },
|
|
||||||
{ week: '2024-W21', new_users: 58 },
|
|
||||||
];
|
|
||||||
} else if (isProducts && !isComparison) {
|
|
||||||
// console.log('✅ Using PRODUCTS data');
|
|
||||||
// 상품별 판매량
|
|
||||||
columns = ['product_name', 'total_sold', 'revenue'];
|
|
||||||
rows = [
|
|
||||||
{ product_name: '스마트폰', total_sold: 156, revenue: 234000000 },
|
|
||||||
{ product_name: '노트북', total_sold: 89, revenue: 178000000 },
|
|
||||||
{ product_name: '태블릿', total_sold: 134, revenue: 67000000 },
|
|
||||||
{ product_name: '이어폰', total_sold: 267, revenue: 26700000 },
|
|
||||||
{ product_name: '스마트워치', total_sold: 98, revenue: 49000000 },
|
|
||||||
{ product_name: '키보드', total_sold: 78, revenue: 15600000 },
|
|
||||||
{ product_name: '마우스', total_sold: 145, revenue: 8700000 },
|
|
||||||
{ product_name: '모니터', total_sold: 67, revenue: 134000000 },
|
|
||||||
{ product_name: '프린터', total_sold: 34, revenue: 17000000 },
|
|
||||||
{ product_name: '웹캠', total_sold: 89, revenue: 8900000 },
|
|
||||||
];
|
|
||||||
} else if (isMonthly && isSales && !isComparison) {
|
|
||||||
// console.log('✅ Using MONTHLY SALES data');
|
|
||||||
// 월별 매출 데이터
|
|
||||||
columns = ['month', 'sales', 'order_count'];
|
|
||||||
rows = [
|
|
||||||
{ month: '2024-01', sales: 1200000, order_count: 45 },
|
|
||||||
{ month: '2024-02', sales: 1350000, order_count: 52 },
|
|
||||||
{ month: '2024-03', sales: 1180000, order_count: 41 },
|
|
||||||
{ month: '2024-04', sales: 1420000, order_count: 58 },
|
|
||||||
{ month: '2024-05', sales: 1680000, order_count: 67 },
|
|
||||||
{ month: '2024-06', sales: 1540000, order_count: 61 },
|
|
||||||
{ month: '2024-07', sales: 1720000, order_count: 71 },
|
|
||||||
{ month: '2024-08', sales: 1580000, order_count: 63 },
|
|
||||||
{ month: '2024-09', sales: 1650000, order_count: 68 },
|
|
||||||
{ month: '2024-10', sales: 1780000, order_count: 75 },
|
|
||||||
{ month: '2024-11', sales: 1920000, order_count: 82 },
|
|
||||||
{ month: '2024-12', sales: 2100000, order_count: 89 },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
// console.log('⚠️ Using DEFAULT data');
|
|
||||||
// 기본 샘플 데이터
|
|
||||||
columns = ['category', 'value', 'count'];
|
|
||||||
rows = [
|
|
||||||
{ category: 'A', value: 100, count: 10 },
|
|
||||||
{ category: 'B', value: 150, count: 15 },
|
|
||||||
{ category: 'C', value: 120, count: 12 },
|
|
||||||
{ category: 'D', value: 180, count: 18 },
|
|
||||||
{ category: 'E', value: 90, count: 9 },
|
|
||||||
{ category: 'F', value: 200, count: 20 },
|
|
||||||
{ category: 'G', value: 110, count: 11 },
|
|
||||||
{ category: 'H', value: 160, count: 16 },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
columns,
|
|
||||||
rows,
|
|
||||||
totalRows: rows.length,
|
|
||||||
executionTime: Math.floor(Math.random() * 200) + 100, // 100-300ms
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
'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,87 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
|
||||||
|
|
||||||
interface BarChartComponentProps {
|
|
||||||
data: any[];
|
|
||||||
config: any;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 바 차트 컴포넌트 (Recharts SimpleBarChart 기반)
|
|
||||||
* - 실제 데이터를 받아서 단순하게 표시
|
|
||||||
* - 복잡한 변환 로직 없음
|
|
||||||
*/
|
|
||||||
export function BarChartComponent({ data, config, width = 600, height = 300 }: BarChartComponentProps) {
|
|
||||||
// console.log('🎨 BarChartComponent - 전체 데이터:', {
|
|
||||||
// dataLength: data?.length,
|
|
||||||
// fullData: data,
|
|
||||||
// dataType: typeof data,
|
|
||||||
// isArray: Array.isArray(data),
|
|
||||||
// config,
|
|
||||||
// xAxisField: config?.xAxis,
|
|
||||||
// yAxisFields: config?.yAxis
|
|
||||||
// });
|
|
||||||
|
|
||||||
// 데이터가 없으면 메시지 표시
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex items-center justify-center text-gray-500">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl mb-2">📊</div>
|
|
||||||
<div>데이터가 없습니다</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 데이터의 첫 번째 아이템에서 사용 가능한 키 확인
|
|
||||||
const firstItem = data[0];
|
|
||||||
const availableKeys = Object.keys(firstItem);
|
|
||||||
// console.log('📊 사용 가능한 데이터 키:', availableKeys);
|
|
||||||
// console.log('📊 첫 번째 데이터 아이템:', firstItem);
|
|
||||||
|
|
||||||
// Y축 필드 추출 (배열이면 모두 사용, 아니면 단일 값)
|
|
||||||
const yFields = Array.isArray(config.yAxis) ? config.yAxis : [config.yAxis];
|
|
||||||
|
|
||||||
// 색상 배열
|
|
||||||
const colors = ['#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1'];
|
|
||||||
|
|
||||||
// 한글 레이블 매핑
|
|
||||||
const labelMapping: Record<string, string> = {
|
|
||||||
'total_users': '전체 사용자',
|
|
||||||
'active_users': '활성 사용자',
|
|
||||||
'name': '부서'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart
|
|
||||||
data={data}
|
|
||||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis
|
|
||||||
dataKey={config.xAxis}
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
/>
|
|
||||||
<YAxis tick={{ fontSize: 12 }} />
|
|
||||||
<Tooltip />
|
|
||||||
{config.showLegend !== false && <Legend />}
|
|
||||||
|
|
||||||
{/* Y축 필드마다 Bar 생성 */}
|
|
||||||
{yFields.map((field: string, index: number) => (
|
|
||||||
<Bar
|
|
||||||
key={field}
|
|
||||||
dataKey={field}
|
|
||||||
fill={colors[index % colors.length]}
|
|
||||||
name={labelMapping[field] || field}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { DashboardElement, QueryResult } from '../types';
|
|
||||||
import { BarChartComponent } from './BarChartComponent';
|
|
||||||
import { PieChartComponent } from './PieChartComponent';
|
|
||||||
import { LineChartComponent } from './LineChartComponent';
|
|
||||||
import { AreaChartComponent } from './AreaChartComponent';
|
|
||||||
import { StackedBarChartComponent } from './StackedBarChartComponent';
|
|
||||||
import { DonutChartComponent } from './DonutChartComponent';
|
|
||||||
import { ComboChartComponent } from './ComboChartComponent';
|
|
||||||
|
|
||||||
interface ChartRendererProps {
|
|
||||||
element: DashboardElement;
|
|
||||||
data?: QueryResult;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 차트 렌더러 컴포넌트 (단순 버전)
|
|
||||||
* - 데이터를 받아서 차트에 그대로 전달
|
|
||||||
* - 복잡한 변환 로직 제거
|
|
||||||
*/
|
|
||||||
export function ChartRenderer({ element, data, width = 250, height = 200 }: ChartRendererProps) {
|
|
||||||
// console.log('🎬 ChartRenderer:', {
|
|
||||||
// elementId: element.id,
|
|
||||||
// hasData: !!data,
|
|
||||||
// dataRows: data?.rows?.length,
|
|
||||||
// xAxis: element.chartConfig?.xAxis,
|
|
||||||
// yAxis: element.chartConfig?.yAxis
|
|
||||||
// });
|
|
||||||
|
|
||||||
// 데이터나 설정이 없으면 메시지 표시
|
|
||||||
if (!data || !element.chartConfig?.xAxis || !element.chartConfig?.yAxis) {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex items-center justify-center text-gray-500 text-sm">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl mb-2">📊</div>
|
|
||||||
<div>데이터를 설정해주세요</div>
|
|
||||||
<div className="text-xs mt-1">⚙️ 버튼을 클릭하여 설정</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 데이터가 비어있으면
|
|
||||||
if (!data.rows || data.rows.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex items-center justify-center text-red-500 text-sm">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl mb-2">⚠️</div>
|
|
||||||
<div>데이터가 없습니다</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 데이터를 그대로 전달 (변환 없음!)
|
|
||||||
const chartData = data.rows;
|
|
||||||
|
|
||||||
// console.log('📊 Chart Data:', {
|
|
||||||
// dataLength: chartData.length,
|
|
||||||
// firstRow: chartData[0],
|
|
||||||
// columns: Object.keys(chartData[0] || {})
|
|
||||||
// });
|
|
||||||
|
|
||||||
// 차트 공통 props
|
|
||||||
const chartProps = {
|
|
||||||
data: chartData,
|
|
||||||
config: element.chartConfig,
|
|
||||||
width: width - 20,
|
|
||||||
height: height - 60,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 차트 타입에 따른 렌더링
|
|
||||||
switch (element.subtype) {
|
|
||||||
case 'bar':
|
|
||||||
return <BarChartComponent {...chartProps} />;
|
|
||||||
case 'pie':
|
|
||||||
return <PieChartComponent {...chartProps} />;
|
|
||||||
case 'line':
|
|
||||||
return <LineChartComponent {...chartProps} />;
|
|
||||||
case 'area':
|
|
||||||
return <AreaChartComponent {...chartProps} />;
|
|
||||||
case 'stacked-bar':
|
|
||||||
return <StackedBarChartComponent {...chartProps} />;
|
|
||||||
case 'donut':
|
|
||||||
return <DonutChartComponent {...chartProps} />;
|
|
||||||
case 'combo':
|
|
||||||
return <ComboChartComponent {...chartProps} />;
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex items-center justify-center text-gray-500 text-sm">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl mb-2">❓</div>
|
|
||||||
<div>지원하지 않는 차트 타입</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer
|
|
||||||
} from 'recharts';
|
|
||||||
import { ChartConfig } from '../types';
|
|
||||||
|
|
||||||
interface LineChartComponentProps {
|
|
||||||
data: any[];
|
|
||||||
config: ChartConfig;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 꺾은선 차트 컴포넌트
|
|
||||||
* - Recharts LineChart 사용
|
|
||||||
* - 다중 라인 지원
|
|
||||||
*/
|
|
||||||
export function LineChartComponent({ data, config, width = 250, height = 200 }: LineChartComponentProps) {
|
|
||||||
const {
|
|
||||||
xAxis = 'x',
|
|
||||||
yAxis = 'y',
|
|
||||||
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'],
|
|
||||||
title,
|
|
||||||
showLegend = true
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
// Y축 필드들 (단일 또는 다중)
|
|
||||||
const yFields = Array.isArray(yAxis) ? yAxis : [yAxis];
|
|
||||||
|
|
||||||
// 사용할 Y축 키들 결정
|
|
||||||
const yKeys = yFields.filter(field => field && field !== 'y');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full p-2">
|
|
||||||
{title && (
|
|
||||||
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart
|
|
||||||
data={data}
|
|
||||||
margin={{
|
|
||||||
top: 5,
|
|
||||||
right: 30,
|
|
||||||
left: 20,
|
|
||||||
bottom: 5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
|
||||||
<XAxis
|
|
||||||
dataKey={xAxis}
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
stroke="#666"
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
stroke="#666"
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
border: '1px solid #ccc',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '12px'
|
|
||||||
}}
|
|
||||||
formatter={(value: any, name: string) => [
|
|
||||||
typeof value === 'number' ? value.toLocaleString() : value,
|
|
||||||
name
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{showLegend && yKeys.length > 1 && (
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: '12px' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{yKeys.map((key, index) => (
|
|
||||||
<Line
|
|
||||||
key={key}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={key}
|
|
||||||
stroke={colors[index % colors.length]}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={{ r: 3 }}
|
|
||||||
activeDot={{ r: 5 }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
PieChart,
|
|
||||||
Pie,
|
|
||||||
Cell,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer
|
|
||||||
} from 'recharts';
|
|
||||||
import { ChartConfig } from '../types';
|
|
||||||
|
|
||||||
interface PieChartComponentProps {
|
|
||||||
data: any[];
|
|
||||||
config: ChartConfig;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원형 차트 컴포넌트
|
|
||||||
* - Recharts PieChart 사용
|
|
||||||
* - 자동 색상 배치 및 레이블
|
|
||||||
*/
|
|
||||||
export function PieChartComponent({ data, config, width = 250, height = 200 }: PieChartComponentProps) {
|
|
||||||
const {
|
|
||||||
xAxis = 'x',
|
|
||||||
yAxis = 'y',
|
|
||||||
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'],
|
|
||||||
title,
|
|
||||||
showLegend = true
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
// 파이 차트용 데이터 변환
|
|
||||||
const pieData = data.map((item, index) => ({
|
|
||||||
name: String(item[xAxis] || `항목 ${index + 1}`),
|
|
||||||
value: Number(item[yAxis]) || 0,
|
|
||||||
color: colors[index % colors.length]
|
|
||||||
})).filter(item => item.value > 0); // 0보다 큰 값만 표시
|
|
||||||
|
|
||||||
// 커스텀 레이블 함수
|
|
||||||
const renderLabel = (entry: any) => {
|
|
||||||
const percent = ((entry.value / pieData.reduce((sum, item) => sum + item.value, 0)) * 100).toFixed(1);
|
|
||||||
return `${percent}%`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full p-2">
|
|
||||||
{title && (
|
|
||||||
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={pieData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
labelLine={false}
|
|
||||||
label={renderLabel}
|
|
||||||
outerRadius={Math.min(width, height) * 0.3}
|
|
||||||
fill="#8884d8"
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{pieData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
border: '1px solid #ccc',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '12px'
|
|
||||||
}}
|
|
||||||
formatter={(value: any, name: string) => [
|
|
||||||
typeof value === 'number' ? value.toLocaleString() : value,
|
|
||||||
name
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showLegend && (
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: '12px' }}
|
|
||||||
iconType="circle"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/**
|
|
||||||
* 차트 컴포넌트 인덱스
|
|
||||||
*/
|
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/**
|
|
||||||
* 대시보드 관리 컴포넌트 인덱스
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { default as DashboardDesigner } from './DashboardDesigner';
|
|
||||||
export { DashboardCanvas } from './DashboardCanvas';
|
|
||||||
export { DashboardSidebar } from './DashboardSidebar';
|
|
||||||
export { DashboardToolbar } from './DashboardToolbar';
|
|
||||||
export { CanvasElement } from './CanvasElement';
|
|
||||||
export { QueryEditor } from './QueryEditor';
|
|
||||||
export { ChartConfigPanel } from './ChartConfigPanel';
|
|
||||||
export { ElementConfigModal } from './ElementConfigModal';
|
|
||||||
export * from './types';
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
/**
|
|
||||||
* 대시보드 관리 시스템 타입 정의
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type ElementType = 'chart' | 'widget';
|
|
||||||
|
|
||||||
export type ElementSubtype =
|
|
||||||
| 'bar' | 'pie' | 'line' | 'area' | 'stacked-bar' | 'donut' | 'combo' // 차트 타입
|
|
||||||
| 'exchange' | 'weather'; // 위젯 타입
|
|
||||||
|
|
||||||
export interface Position {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Size {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardElement {
|
|
||||||
id: string;
|
|
||||||
type: ElementType;
|
|
||||||
subtype: ElementSubtype;
|
|
||||||
position: Position;
|
|
||||||
size: Size;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
dataSource?: ChartDataSource; // 데이터 소스 설정
|
|
||||||
chartConfig?: ChartConfig; // 차트 설정
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DragData {
|
|
||||||
type: ElementType;
|
|
||||||
subtype: ElementSubtype;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResizeHandle {
|
|
||||||
direction: 'nw' | 'ne' | 'sw' | 'se';
|
|
||||||
cursor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChartDataSource {
|
|
||||||
type: 'api' | 'database' | 'static';
|
|
||||||
endpoint?: string; // API 엔드포인트
|
|
||||||
query?: string; // SQL 쿼리
|
|
||||||
refreshInterval?: number; // 자동 새로고침 간격 (ms)
|
|
||||||
filters?: any[]; // 필터 조건
|
|
||||||
lastExecuted?: string; // 마지막 실행 시간
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChartConfig {
|
|
||||||
xAxis?: string; // X축 데이터 필드
|
|
||||||
yAxis?: string | string[]; // Y축 데이터 필드 (단일 또는 다중)
|
|
||||||
groupBy?: string; // 그룹핑 필드
|
|
||||||
aggregation?: 'sum' | 'avg' | 'count' | 'max' | 'min';
|
|
||||||
colors?: string[]; // 차트 색상
|
|
||||||
title?: string; // 차트 제목
|
|
||||||
showLegend?: boolean; // 범례 표시 여부
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueryResult {
|
|
||||||
columns: string[]; // 컬럼명 배열
|
|
||||||
rows: Record<string, any>[]; // 데이터 행 배열
|
|
||||||
totalRows: number; // 전체 행 수
|
|
||||||
executionTime: number; // 실행 시간 (ms)
|
|
||||||
error?: string; // 오류 메시지
|
|
||||||
}
|
|
||||||
|
|
@ -1,277 +0,0 @@
|
||||||
'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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,281 +0,0 @@
|
||||||
/**
|
|
||||||
* 대시보드 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 '알 수 없는 오류가 발생했습니다.';
|
|
||||||
}
|
|
||||||
|
|
@ -49,7 +49,6 @@
|
||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-window": "^2.1.0",
|
"react-window": "^2.1.0",
|
||||||
"recharts": "^3.2.1",
|
|
||||||
"sheetjs-style": "^0.15.8",
|
"sheetjs-style": "^0.15.8",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
|
@ -2280,32 +2279,6 @@
|
||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
|
||||||
"version": "2.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
|
|
||||||
"integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@standard-schema/spec": "^1.0.0",
|
|
||||||
"@standard-schema/utils": "^0.3.0",
|
|
||||||
"immer": "^10.0.3",
|
|
||||||
"redux": "^5.0.1",
|
|
||||||
"redux-thunk": "^3.1.0",
|
|
||||||
"reselect": "^5.1.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
|
||||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-redux": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
|
|
@ -2324,6 +2297,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/utils": {
|
"node_modules/@standard-schema/utils": {
|
||||||
|
|
@ -2716,12 +2690,6 @@
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-array": {
|
|
||||||
"version": "3.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
|
||||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-color": {
|
"node_modules/@types/d3-color": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
|
@ -2737,12 +2705,6 @@
|
||||||
"@types/d3-selection": "*"
|
"@types/d3-selection": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-ease": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-interpolate": {
|
"node_modules/@types/d3-interpolate": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
|
@ -2752,48 +2714,12 @@
|
||||||
"@types/d3-color": "*"
|
"@types/d3-color": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-path": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-scale": {
|
|
||||||
"version": "4.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
|
||||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-time": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-selection": {
|
"node_modules/@types/d3-selection": {
|
||||||
"version": "3.0.11",
|
"version": "3.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-shape": {
|
|
||||||
"version": "3.1.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
|
||||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-path": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-time": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-timer": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-transition": {
|
"node_modules/@types/d3-transition": {
|
||||||
"version": "3.0.9",
|
"version": "3.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||||
|
|
@ -2872,12 +2798,6 @@
|
||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
|
||||||
"version": "0.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
|
||||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.44.1",
|
"version": "8.44.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz",
|
||||||
|
|
@ -4219,18 +4139,6 @@
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3-array": {
|
|
||||||
"version": "3.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
|
||||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"internmap": "1 - 2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-color": {
|
"node_modules/d3-color": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
|
@ -4271,15 +4179,6 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-format": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-interpolate": {
|
"node_modules/d3-interpolate": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
|
@ -4292,31 +4191,6 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-path": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-scale": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2.10.0 - 3",
|
|
||||||
"d3-format": "1 - 3",
|
|
||||||
"d3-interpolate": "1.2.0 - 3",
|
|
||||||
"d3-time": "2.1.1 - 3",
|
|
||||||
"d3-time-format": "2 - 4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-selection": {
|
"node_modules/d3-selection": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
|
|
@ -4326,42 +4200,6 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-shape": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-path": "^3.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-time": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-time-format": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-time": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-timer": {
|
"node_modules/d3-timer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
|
@ -4501,12 +4339,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/decimal.js-light": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
|
|
@ -4878,16 +4710,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-toolkit": {
|
|
||||||
"version": "1.39.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz",
|
|
||||||
"integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"docs",
|
|
||||||
"benchmarks"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/escape-string-regexp": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
|
|
@ -5374,12 +5196,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eventemitter3": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/exit-on-epipe": {
|
"node_modules/exit-on-epipe": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
||||||
|
|
@ -5932,16 +5748,6 @@
|
||||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/immer": {
|
|
||||||
"version": "10.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
|
|
||||||
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/immer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|
@ -5990,15 +5796,6 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/internmap": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
|
|
@ -7839,31 +7636,9 @@
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-redux": {
|
|
||||||
"version": "9.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
|
||||||
"use-sync-external-store": "^1.4.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "^18.2.25 || ^19",
|
|
||||||
"react": "^18.0 || ^19",
|
|
||||||
"redux": "^5.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"redux": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.1",
|
"version": "2.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
|
||||||
|
|
@ -7978,48 +7753,6 @@
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/recharts": {
|
|
||||||
"version": "3.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.1.tgz",
|
|
||||||
"integrity": "sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"decimal.js-light": "^2.5.1",
|
|
||||||
"es-toolkit": "^1.39.3",
|
|
||||||
"eventemitter3": "^5.0.1",
|
|
||||||
"immer": "^10.1.1",
|
|
||||||
"react-redux": "8.x.x || 9.x.x",
|
|
||||||
"reselect": "5.1.1",
|
|
||||||
"tiny-invariant": "^1.3.3",
|
|
||||||
"use-sync-external-store": "^1.2.2",
|
|
||||||
"victory-vendor": "^37.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/redux": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/redux-thunk": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"redux": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
|
|
@ -8064,12 +7797,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/reselect": {
|
|
||||||
"version": "5.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
|
||||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.10",
|
"version": "1.22.10",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||||
|
|
@ -8764,12 +8491,6 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tiny-invariant": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tinyexec": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
|
||||||
|
|
@ -9120,28 +8841,6 @@
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/victory-vendor": {
|
|
||||||
"version": "37.3.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
|
||||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
|
||||||
"license": "MIT AND ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-array": "^3.0.3",
|
|
||||||
"@types/d3-ease": "^3.0.0",
|
|
||||||
"@types/d3-interpolate": "^3.0.1",
|
|
||||||
"@types/d3-scale": "^4.0.2",
|
|
||||||
"@types/d3-shape": "^3.1.0",
|
|
||||||
"@types/d3-time": "^3.0.0",
|
|
||||||
"@types/d3-timer": "^3.0.0",
|
|
||||||
"d3-array": "^3.1.6",
|
|
||||||
"d3-ease": "^3.0.1",
|
|
||||||
"d3-interpolate": "^3.0.1",
|
|
||||||
"d3-scale": "^4.0.2",
|
|
||||||
"d3-shape": "^3.1.0",
|
|
||||||
"d3-time": "^3.0.0",
|
|
||||||
"d3-timer": "^3.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,6 @@
|
||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-window": "^2.1.0",
|
"react-window": "^2.1.0",
|
||||||
"recharts": "^3.2.1",
|
|
||||||
"sheetjs-style": "^0.15.8",
|
"sheetjs-style": "^0.15.8",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue