차트 구현 및 리스트 구현 #98
|
|
@ -27,6 +27,7 @@
|
|||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.15.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
|
|
@ -48,6 +49,7 @@
|
|||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/nodemailer": "^6.4.20",
|
||||
"@types/oracledb": "^6.9.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
|
|
@ -3380,6 +3382,17 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node-fetch": {
|
||||
"version": "2.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
|
||||
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.20.tgz",
|
||||
|
|
@ -8116,6 +8129,26 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
|
|
@ -9861,6 +9894,12 @@
|
|||
"nodetouch": "bin/nodetouch.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/triple-beam": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
|
||||
|
|
@ -10237,6 +10276,22 @@
|
|||
"makeerror": "1.0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.15.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
|
|
@ -62,6 +63,7 @@
|
|||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/nodemailer": "^6.4.20",
|
||||
"@types/oracledb": "^6.9.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
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';
|
||||
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";
|
||||
|
||||
/**
|
||||
* 대시보드 컨트롤러
|
||||
|
|
@ -10,80 +14,91 @@ import { PostgreSQLService } from '../database/PostgreSQLService';
|
|||
* - 요청 검증 및 응답 포맷팅
|
||||
*/
|
||||
export class DashboardController {
|
||||
|
||||
/**
|
||||
* 대시보드 생성
|
||||
* POST /api/dashboards
|
||||
*/
|
||||
async createDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
async createDashboard(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '인증이 필요합니다.'
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, description, elements, isPublic = false, tags, category }: CreateDashboardRequest = req.body;
|
||||
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
elements,
|
||||
isPublic = false,
|
||||
tags,
|
||||
category,
|
||||
}: CreateDashboardRequest = req.body;
|
||||
|
||||
// 유효성 검증
|
||||
if (!title || title.trim().length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '대시보드 제목이 필요합니다.'
|
||||
message: "대시보드 제목이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!elements || !Array.isArray(elements)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '대시보드 요소 데이터가 필요합니다.'
|
||||
message: "대시보드 요소 데이터가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 제목 길이 체크
|
||||
if (title.length > 200) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '제목은 200자를 초과할 수 없습니다.'
|
||||
message: "제목은 200자를 초과할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 설명 길이 체크
|
||||
if (description && description.length > 1000) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '설명은 1000자를 초과할 수 없습니다.'
|
||||
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 });
|
||||
|
||||
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: '대시보드가 성공적으로 생성되었습니다.'
|
||||
message: "대시보드가 성공적으로 생성되었습니다.",
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
// console.error('Dashboard creation error:', {
|
||||
// message: error?.message,
|
||||
|
|
@ -92,12 +107,13 @@ export class DashboardController {
|
|||
// });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error?.message || '대시보드 생성 중 오류가 발생했습니다.',
|
||||
error: process.env.NODE_ENV === 'development' ? error?.message : undefined
|
||||
message: error?.message || "대시보드 생성 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development" ? error?.message : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 대시보드 목록 조회
|
||||
* GET /api/dashboards
|
||||
|
|
@ -105,43 +121,50 @@ export class DashboardController {
|
|||
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
|
||||
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 이상이어야 합니다.'
|
||||
message: "페이지 번호는 1 이상이어야 합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const result = await DashboardService.getDashboards(query, userId);
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.dashboards,
|
||||
pagination: result.pagination
|
||||
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
|
||||
message: "대시보드 목록 조회 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 대시보드 상세 조회
|
||||
* GET /api/dashboards/:id
|
||||
|
|
@ -150,222 +173,250 @@ export class DashboardController {
|
|||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '대시보드 ID가 필요합니다.'
|
||||
message: "대시보드 ID가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const dashboard = await DashboardService.getDashboardById(id, userId);
|
||||
|
||||
|
||||
if (!dashboard) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: '대시보드를 찾을 수 없거나 접근 권한이 없습니다.'
|
||||
message: "대시보드를 찾을 수 없거나 접근 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 조회수 증가 (본인이 만든 대시보드가 아닌 경우에만)
|
||||
if (userId && dashboard.createdBy !== userId) {
|
||||
await DashboardService.incrementViewCount(id);
|
||||
}
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dashboard
|
||||
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
|
||||
message: "대시보드 조회 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 대시보드 수정
|
||||
* PUT /api/dashboards/:id
|
||||
*/
|
||||
async updateDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
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: '인증이 필요합니다.'
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '대시보드 ID가 필요합니다.'
|
||||
message: "대시보드 ID가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const updateData: UpdateDashboardRequest = req.body;
|
||||
|
||||
|
||||
// 유효성 검증
|
||||
if (updateData.title !== undefined) {
|
||||
if (typeof updateData.title !== 'string' || updateData.title.trim().length === 0) {
|
||||
if (
|
||||
typeof updateData.title !== "string" ||
|
||||
updateData.title.trim().length === 0
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '올바른 제목을 입력해주세요.'
|
||||
message: "올바른 제목을 입력해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (updateData.title.length > 200) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '제목은 200자를 초과할 수 없습니다.'
|
||||
message: "제목은 200자를 초과할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateData.title = updateData.title.trim();
|
||||
}
|
||||
|
||||
if (updateData.description !== undefined && updateData.description && updateData.description.length > 1000) {
|
||||
|
||||
if (
|
||||
updateData.description !== undefined &&
|
||||
updateData.description &&
|
||||
updateData.description.length > 1000
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '설명은 1000자를 초과할 수 없습니다.'
|
||||
message: "설명은 1000자를 초과할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedDashboard = await DashboardService.updateDashboard(id, updateData, userId);
|
||||
|
||||
|
||||
const updatedDashboard = await DashboardService.updateDashboard(
|
||||
id,
|
||||
updateData,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!updatedDashboard) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: '대시보드를 찾을 수 없거나 수정 권한이 없습니다.'
|
||||
message: "대시보드를 찾을 수 없거나 수정 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: updatedDashboard,
|
||||
message: '대시보드가 성공적으로 수정되었습니다.'
|
||||
message: "대시보드가 성공적으로 수정되었습니다.",
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// console.error('Dashboard update error:', error);
|
||||
|
||||
if ((error as Error).message.includes('권한이 없습니다')) {
|
||||
|
||||
if ((error as Error).message.includes("권한이 없습니다")) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: (error as Error).message
|
||||
message: (error as Error).message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '대시보드 수정 중 오류가 발생했습니다.',
|
||||
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
||||
message: "대시보드 수정 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 대시보드 삭제
|
||||
* DELETE /api/dashboards/:id
|
||||
*/
|
||||
async deleteDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
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: '인증이 필요합니다.'
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '대시보드 ID가 필요합니다.'
|
||||
message: "대시보드 ID가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const deleted = await DashboardService.deleteDashboard(id, userId);
|
||||
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: '대시보드를 찾을 수 없거나 삭제 권한이 없습니다.'
|
||||
message: "대시보드를 찾을 수 없거나 삭제 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '대시보드가 성공적으로 삭제되었습니다.'
|
||||
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
|
||||
message: "대시보드 삭제 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 내 대시보드 목록 조회
|
||||
* GET /api/dashboards/my
|
||||
*/
|
||||
async getMyDashboards(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
async getMyDashboards(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '인증이 필요합니다.'
|
||||
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 // 본인이 만든 대시보드만
|
||||
createdBy: userId, // 본인이 만든 대시보드만
|
||||
};
|
||||
|
||||
|
||||
const result = await DashboardService.getDashboards(query, userId);
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.dashboards,
|
||||
pagination: result.pagination
|
||||
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
|
||||
message: "내 대시보드 목록 조회 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -387,31 +438,31 @@ export class DashboardController {
|
|||
// }
|
||||
|
||||
const { query } = req.body;
|
||||
|
||||
|
||||
// 유효성 검증
|
||||
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
||||
if (!query || typeof query !== "string" || query.trim().length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '쿼리가 필요합니다.'
|
||||
message: "쿼리가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// SQL 인젝션 방지를 위한 기본적인 검증
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
if (!trimmedQuery.startsWith('select')) {
|
||||
if (!trimmedQuery.startsWith("select")) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'SELECT 쿼리만 허용됩니다.'
|
||||
message: "SELECT 쿼리만 허용됩니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 쿼리 실행
|
||||
const result = await PostgreSQLService.query(query.trim());
|
||||
|
||||
|
||||
// 결과 변환
|
||||
const columns = result.fields?.map(field => field.name) || [];
|
||||
const columns = result.fields?.map((field) => field.name) || [];
|
||||
const rows = result.rows || [];
|
||||
|
||||
res.status(200).json({
|
||||
|
|
@ -419,18 +470,81 @@ export class DashboardController {
|
|||
data: {
|
||||
columns,
|
||||
rows,
|
||||
rowCount: rows.length
|
||||
rowCount: rows.length,
|
||||
},
|
||||
message: '쿼리가 성공적으로 실행되었습니다.'
|
||||
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 : '쿼리 실행 오류'
|
||||
message: "쿼리 실행 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: "쿼리 실행 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API 프록시 (CORS 우회용)
|
||||
* POST /api/dashboards/fetch-external-api
|
||||
*/
|
||||
async fetchExternalApi(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { url, method = "GET", headers = {}, queryParams = {} } = req.body;
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "URL이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 쿼리 파라미터 추가
|
||||
const urlObj = new URL(url);
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
urlObj.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// 외부 API 호출
|
||||
const fetch = (await import("node-fetch")).default;
|
||||
const response = await fetch(urlObj.toString(), {
|
||||
method: method.toUpperCase(),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`외부 API 오류: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 API 호출 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: "외부 API 호출 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,61 @@
|
|||
import { Router } from 'express';
|
||||
import { DashboardController } from '../controllers/DashboardController';
|
||||
import { authenticateToken } from '../middleware/authMiddleware';
|
||||
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",
|
||||
dashboardController.getDashboards.bind(dashboardController)
|
||||
);
|
||||
|
||||
// 공개 대시보드 상세 조회 (인증 불필요)
|
||||
router.get('/public/:id', dashboardController.getDashboard.bind(dashboardController));
|
||||
router.get(
|
||||
"/public/:id",
|
||||
dashboardController.getDashboard.bind(dashboardController)
|
||||
);
|
||||
|
||||
// 쿼리 실행 (인증 불필요 - 개발용)
|
||||
router.post('/execute-query', dashboardController.executeQuery.bind(dashboardController));
|
||||
router.post(
|
||||
"/execute-query",
|
||||
dashboardController.executeQuery.bind(dashboardController)
|
||||
);
|
||||
|
||||
// 외부 API 프록시 (CORS 우회)
|
||||
router.post(
|
||||
"/fetch-external-api",
|
||||
dashboardController.fetchExternalApi.bind(dashboardController)
|
||||
);
|
||||
|
||||
// 인증이 필요한 라우트들
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 내 대시보드 목록 조회
|
||||
router.get('/my', dashboardController.getMyDashboards.bind(dashboardController));
|
||||
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));
|
||||
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,13 +1,14 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { DashboardViewer } from '@/components/dashboard/DashboardViewer';
|
||||
import { DashboardElement } from '@/components/admin/dashboard/types';
|
||||
import React, { useState, useEffect, use } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DashboardViewer } from "@/components/dashboard/DashboardViewer";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
|
||||
interface DashboardViewPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
dashboardId: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -17,6 +18,8 @@ interface DashboardViewPageProps {
|
|||
* - 전체화면 모드 지원
|
||||
*/
|
||||
export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||
const router = useRouter();
|
||||
const resolvedParams = use(params);
|
||||
const [dashboard, setDashboard] = useState<{
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -31,7 +34,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
// 대시보드 데이터 로딩
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
}, [params.dashboardId]);
|
||||
}, [resolvedParams.dashboardId]);
|
||||
|
||||
const loadDashboard = async () => {
|
||||
setIsLoading(true);
|
||||
|
|
@ -39,29 +42,29 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
|
||||
try {
|
||||
// 실제 API 호출 시도
|
||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
||||
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
|
||||
try {
|
||||
const dashboardData = await dashboardApi.getDashboard(params.dashboardId);
|
||||
const dashboardData = await dashboardApi.getDashboard(resolvedParams.dashboardId);
|
||||
setDashboard(dashboardData);
|
||||
} catch (apiError) {
|
||||
console.warn('API 호출 실패, 로컬 스토리지 확인:', apiError);
|
||||
|
||||
console.warn("API 호출 실패, 로컬 스토리지 확인:", apiError);
|
||||
|
||||
// API 실패 시 로컬 스토리지에서 찾기
|
||||
const savedDashboards = JSON.parse(localStorage.getItem('savedDashboards') || '[]');
|
||||
const savedDashboard = savedDashboards.find((d: any) => d.id === params.dashboardId);
|
||||
|
||||
const savedDashboards = JSON.parse(localStorage.getItem("savedDashboards") || "[]");
|
||||
const savedDashboard = savedDashboards.find((d: any) => d.id === resolvedParams.dashboardId);
|
||||
|
||||
if (savedDashboard) {
|
||||
setDashboard(savedDashboard);
|
||||
} else {
|
||||
// 로컬에도 없으면 샘플 데이터 사용
|
||||
const sampleDashboard = generateSampleDashboard(params.dashboardId);
|
||||
const sampleDashboard = generateSampleDashboard(resolvedParams.dashboardId);
|
||||
setDashboard(sampleDashboard);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError('대시보드를 불러오는 중 오류가 발생했습니다.');
|
||||
console.error('Dashboard loading error:', err);
|
||||
setError("대시보드를 불러오는 중 오류가 발생했습니다.");
|
||||
console.error("Dashboard loading error:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -70,11 +73,11 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="flex h-screen 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="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||
<div className="text-lg font-medium text-gray-700">대시보드 로딩 중...</div>
|
||||
<div className="text-sm text-gray-500 mt-1">잠시만 기다려주세요</div>
|
||||
<div className="mt-1 text-sm text-gray-500">잠시만 기다려주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -83,19 +86,12 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
// 에러 상태
|
||||
if (error || !dashboard) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="flex h-screen 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"
|
||||
>
|
||||
<div className="mb-4 text-6xl">😞</div>
|
||||
<div className="mb-2 text-xl font-medium text-gray-700">{error || "대시보드를 찾을 수 없습니다"}</div>
|
||||
<div className="mb-4 text-sm text-gray-500">대시보드 ID: {resolvedParams.dashboardId}</div>
|
||||
<button onClick={loadDashboard} className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600">
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -106,25 +102,23 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
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 className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
)}
|
||||
{dashboard.description && <p className="mt-1 text-sm text-gray-600">{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"
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-gray-600 hover:bg-gray-50 hover:text-gray-800"
|
||||
title="새로고침"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
|
||||
|
||||
{/* 전체화면 버튼 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -134,26 +128,26 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
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"
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-gray-600 hover:bg-gray-50 hover:text-gray-800"
|
||||
title="전체화면"
|
||||
>
|
||||
⛶
|
||||
</button>
|
||||
|
||||
|
||||
{/* 편집 버튼 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.open(`/admin/dashboard?load=${params.dashboardId}`, '_blank');
|
||||
router.push(`/admin/dashboard?load=${resolvedParams.dashboardId}`);
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
||||
className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 메타 정보 */}
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>생성: {new Date(dashboard.createdAt).toLocaleString()}</span>
|
||||
<span>수정: {new Date(dashboard.updatedAt).toLocaleString()}</span>
|
||||
<span>요소: {dashboard.elements.length}개</span>
|
||||
|
|
@ -162,10 +156,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
|
||||
{/* 대시보드 뷰어 */}
|
||||
<div className="h-[calc(100vh-120px)]">
|
||||
<DashboardViewer
|
||||
elements={dashboard.elements}
|
||||
dashboardId={dashboard.id}
|
||||
/>
|
||||
<DashboardViewer elements={dashboard.elements} dashboardId={dashboard.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -176,111 +167,113 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
*/
|
||||
function generateSampleDashboard(dashboardId: string) {
|
||||
const dashboards: Record<string, any> = {
|
||||
'sales-overview': {
|
||||
id: 'sales-overview',
|
||||
title: '📊 매출 현황 대시보드',
|
||||
description: '월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.',
|
||||
"sales-overview": {
|
||||
id: "sales-overview",
|
||||
title: "📊 매출 현황 대시보드",
|
||||
description: "월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.",
|
||||
elements: [
|
||||
{
|
||||
id: 'chart-1',
|
||||
type: 'chart',
|
||||
subtype: 'bar',
|
||||
id: "chart-1",
|
||||
type: "chart",
|
||||
subtype: "bar",
|
||||
position: { x: 20, y: 20 },
|
||||
size: { width: 400, height: 300 },
|
||||
title: '📊 월별 매출 추이',
|
||||
content: '월별 매출 데이터',
|
||||
title: "📊 월별 매출 추이",
|
||||
content: "월별 매출 데이터",
|
||||
dataSource: {
|
||||
type: 'database',
|
||||
query: 'SELECT month, sales FROM monthly_sales',
|
||||
refreshInterval: 30000
|
||||
type: "database",
|
||||
query: "SELECT month, sales FROM monthly_sales",
|
||||
refreshInterval: 30000,
|
||||
},
|
||||
chartConfig: {
|
||||
xAxis: 'month',
|
||||
yAxis: 'sales',
|
||||
title: '월별 매출 추이',
|
||||
colors: ['#3B82F6', '#EF4444', '#10B981']
|
||||
}
|
||||
xAxis: "month",
|
||||
yAxis: "sales",
|
||||
title: "월별 매출 추이",
|
||||
colors: ["#3B82F6", "#EF4444", "#10B981"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'chart-2',
|
||||
type: 'chart',
|
||||
subtype: 'pie',
|
||||
id: "chart-2",
|
||||
type: "chart",
|
||||
subtype: "pie",
|
||||
position: { x: 450, y: 20 },
|
||||
size: { width: 350, height: 300 },
|
||||
title: '🥧 상품별 판매 비율',
|
||||
content: '상품별 판매 데이터',
|
||||
title: "🥧 상품별 판매 비율",
|
||||
content: "상품별 판매 데이터",
|
||||
dataSource: {
|
||||
type: 'database',
|
||||
query: 'SELECT product_name, total_sold FROM product_sales',
|
||||
refreshInterval: 60000
|
||||
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']
|
||||
}
|
||||
xAxis: "product_name",
|
||||
yAxis: "total_sold",
|
||||
title: "상품별 판매 비율",
|
||||
colors: ["#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'chart-3',
|
||||
type: 'chart',
|
||||
subtype: 'line',
|
||||
id: "chart-3",
|
||||
type: "chart",
|
||||
subtype: "line",
|
||||
position: { x: 20, y: 350 },
|
||||
size: { width: 780, height: 250 },
|
||||
title: '📈 사용자 가입 추이',
|
||||
content: '사용자 가입 데이터',
|
||||
title: "📈 사용자 가입 추이",
|
||||
content: "사용자 가입 데이터",
|
||||
dataSource: {
|
||||
type: 'database',
|
||||
query: 'SELECT week, new_users FROM user_growth',
|
||||
refreshInterval: 300000
|
||||
type: "database",
|
||||
query: "SELECT week, new_users FROM user_growth",
|
||||
refreshInterval: 300000,
|
||||
},
|
||||
chartConfig: {
|
||||
xAxis: 'week',
|
||||
yAxis: 'new_users',
|
||||
title: '주간 신규 사용자 가입 추이',
|
||||
colors: ['#10B981']
|
||||
}
|
||||
}
|
||||
xAxis: "week",
|
||||
yAxis: "new_users",
|
||||
title: "주간 신규 사용자 가입 추이",
|
||||
colors: ["#10B981"],
|
||||
},
|
||||
},
|
||||
],
|
||||
createdAt: '2024-09-30T10:00:00Z',
|
||||
updatedAt: '2024-09-30T14:30:00Z'
|
||||
createdAt: "2024-09-30T10:00:00Z",
|
||||
updatedAt: "2024-09-30T14:30:00Z",
|
||||
},
|
||||
'user-analytics': {
|
||||
id: 'user-analytics',
|
||||
title: '👥 사용자 분석 대시보드',
|
||||
description: '사용자 행동 패턴 및 가입 추이 분석',
|
||||
"user-analytics": {
|
||||
id: "user-analytics",
|
||||
title: "👥 사용자 분석 대시보드",
|
||||
description: "사용자 행동 패턴 및 가입 추이 분석",
|
||||
elements: [
|
||||
{
|
||||
id: 'chart-4',
|
||||
type: 'chart',
|
||||
subtype: 'line',
|
||||
id: "chart-4",
|
||||
type: "chart",
|
||||
subtype: "line",
|
||||
position: { x: 20, y: 20 },
|
||||
size: { width: 500, height: 300 },
|
||||
title: '📈 일일 활성 사용자',
|
||||
content: '사용자 활동 데이터',
|
||||
title: "📈 일일 활성 사용자",
|
||||
content: "사용자 활동 데이터",
|
||||
dataSource: {
|
||||
type: 'database',
|
||||
query: 'SELECT date, active_users FROM daily_active_users',
|
||||
refreshInterval: 60000
|
||||
type: "database",
|
||||
query: "SELECT date, active_users FROM daily_active_users",
|
||||
refreshInterval: 60000,
|
||||
},
|
||||
chartConfig: {
|
||||
xAxis: 'date',
|
||||
yAxis: 'active_users',
|
||||
title: '일일 활성 사용자 추이'
|
||||
}
|
||||
}
|
||||
xAxis: "date",
|
||||
yAxis: "active_users",
|
||||
title: "일일 활성 사용자 추이",
|
||||
},
|
||||
},
|
||||
],
|
||||
createdAt: '2024-09-29T15:00:00Z',
|
||||
updatedAt: '2024-09-30T09:15:00Z'
|
||||
}
|
||||
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()
|
||||
};
|
||||
return (
|
||||
dashboards[dashboardId] || {
|
||||
id: dashboardId,
|
||||
title: `대시보드 ${dashboardId}`,
|
||||
description: "샘플 대시보드입니다.",
|
||||
elements: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,742 @@
|
|||
# 📊 차트 시스템 구현 계획
|
||||
|
||||
## 개요
|
||||
|
||||
D3.js 기반의 강력한 차트 시스템을 구축합니다. 사용자는 데이터를 두 가지 방법(DB 쿼리 또는 REST API)으로 가져와 다양한 차트로 시각화할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 핵심 요구사항
|
||||
|
||||
### 1. 데이터 소스 (2가지 방식)
|
||||
|
||||
#### A. 데이터베이스 커넥션
|
||||
|
||||
- **현재 DB**: 애플리케이션의 기본 PostgreSQL 연결
|
||||
- **외부 DB**: 기존 "외부 커넥션 관리" 메뉴에서 등록된 커넥션만 사용
|
||||
- 신규 커넥션 생성은 외부 커넥션 관리 메뉴에서만 가능
|
||||
- 차트 설정에서는 등록된 커넥션 목록에서 선택만 가능
|
||||
- **쿼리 제한**: SELECT 문만 허용 (INSERT, UPDATE, DELETE, DROP 등 금지)
|
||||
- **쿼리 검증**: 서버 측에서 SQL Injection 방지 및 쿼리 타입 검증
|
||||
|
||||
#### B. REST API 호출
|
||||
|
||||
- **HTTP Methods**: GET (권장) - 데이터 조회에 충분
|
||||
- **데이터 형식**: JSON 응답만 허용
|
||||
- **헤더 설정**: Authorization, Content-Type 등 커스텀 헤더 지원
|
||||
- **쿼리 파라미터**: URL 파라미터로 필터링 조건 전달
|
||||
- **응답 파싱**: JSON 구조에서 차트 데이터 추출
|
||||
- **에러 처리**: HTTP 상태 코드 및 타임아웃 처리
|
||||
|
||||
> **참고**: POST는 향후 확장 (GraphQL, 복잡한 필터링)을 위해 선택적으로 지원 가능
|
||||
|
||||
### 2. 차트 타입 (D3.js 기반)
|
||||
|
||||
현재 지원 예정:
|
||||
|
||||
- **Bar Chart** (막대 차트): 수평/수직 막대
|
||||
- **Line Chart** (선 차트): 단일/다중 시리즈
|
||||
- **Area Chart** (영역 차트): 누적 영역 지원
|
||||
- **Pie Chart** (원 차트): 도넛 차트 포함
|
||||
- **Stacked Bar** (누적 막대): 다중 시리즈 누적
|
||||
- **Combo Chart** (혼합 차트): 막대 + 선 조합
|
||||
|
||||
### 3. 축 매핑 설정
|
||||
|
||||
- **X축**: 카테고리/시간 데이터 (문자열, 날짜)
|
||||
- **Y축**: 숫자 데이터 (단일 또는 다중 선택 가능)
|
||||
- **다중 Y축**: 여러 시리즈를 한 차트에 표시 (예: 갤럭시 vs 아이폰 매출)
|
||||
- **자동 감지**: 데이터 타입에 따라 축 자동 추천
|
||||
- **데이터 변환**: 문자열 날짜를 Date 객체로 자동 변환
|
||||
|
||||
### 4. 차트 스타일링
|
||||
|
||||
- **색상 팔레트**: 사전 정의된 색상 세트 선택
|
||||
- **커스텀 색상**: 사용자 지정 색상 입력
|
||||
- **범례**: 위치 설정 (상단, 하단, 좌측, 우측, 숨김)
|
||||
- **애니메이션**: 차트 로드 시 부드러운 전환 효과
|
||||
- **툴팁**: 데이터 포인트 호버 시 상세 정보 표시
|
||||
- **그리드**: X/Y축 그리드 라인 표시/숨김
|
||||
|
||||
---
|
||||
|
||||
## 📁 파일 구조
|
||||
|
||||
```
|
||||
frontend/components/admin/dashboard/
|
||||
├── CHART_SYSTEM_PLAN.md # 이 파일
|
||||
├── types.ts # ✅ 기존 (타입 확장 필요)
|
||||
├── ElementConfigModal.tsx # ✅ 기존 (리팩토링 필요)
|
||||
│
|
||||
├── data-sources/ # 🆕 데이터 소스 관련
|
||||
│ ├── DataSourceSelector.tsx # 데이터 소스 선택 UI (DB vs API)
|
||||
│ ├── DatabaseConfig.tsx # DB 커넥션 설정 UI
|
||||
│ ├── ApiConfig.tsx # REST API 설정 UI
|
||||
│ └── dataSourceUtils.ts # 데이터 소스 유틸리티
|
||||
│
|
||||
├── chart-config/ # 🔄 차트 설정 관련 (리팩토링)
|
||||
│ ├── QueryEditor.tsx # ✅ 기존 (확장 필요)
|
||||
│ ├── ChartConfigPanel.tsx # ✅ 기존 (확장 필요)
|
||||
│ ├── AxisMapper.tsx # 🆕 축 매핑 UI
|
||||
│ ├── StyleConfig.tsx # 🆕 스타일 설정 UI
|
||||
│ └── ChartPreview.tsx # 🆕 실시간 미리보기
|
||||
│
|
||||
├── charts/ # 🆕 D3 차트 컴포넌트
|
||||
│ ├── ChartRenderer.tsx # 차트 렌더러 (메인)
|
||||
│ ├── BarChart.tsx # 막대 차트
|
||||
│ ├── LineChart.tsx # 선 차트
|
||||
│ ├── AreaChart.tsx # 영역 차트
|
||||
│ ├── PieChart.tsx # 원 차트
|
||||
│ ├── StackedBarChart.tsx # 누적 막대 차트
|
||||
│ ├── ComboChart.tsx # 혼합 차트
|
||||
│ ├── chartUtils.ts # 차트 유틸리티
|
||||
│ └── d3Helpers.ts # D3 헬퍼 함수
|
||||
│
|
||||
└── CanvasElement.tsx # ✅ 기존 (차트 렌더링 통합)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 타입 정의 확장
|
||||
|
||||
### 기존 타입 업데이트
|
||||
|
||||
```typescript
|
||||
// types.ts
|
||||
|
||||
// 데이터 소스 타입 확장
|
||||
export interface ChartDataSource {
|
||||
type: "database" | "api"; // 'static' 제거
|
||||
|
||||
// DB 커넥션 관련
|
||||
connectionType?: "current" | "external"; // 현재 DB vs 외부 DB
|
||||
externalConnectionId?: string; // 외부 DB 커넥션 ID
|
||||
query?: string; // SQL 쿼리 (SELECT만)
|
||||
|
||||
// API 관련
|
||||
endpoint?: string; // API URL
|
||||
method?: "GET"; // HTTP 메서드 (GET만 지원)
|
||||
headers?: Record<string, string>; // 커스텀 헤더
|
||||
queryParams?: Record<string, string>; // URL 쿼리 파라미터
|
||||
jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results")
|
||||
|
||||
// 공통
|
||||
refreshInterval?: number; // 자동 새로고침 (초)
|
||||
lastExecuted?: string; // 마지막 실행 시간
|
||||
lastError?: string; // 마지막 오류 메시지
|
||||
}
|
||||
|
||||
// 외부 DB 커넥션 정보 (기존 외부 커넥션 관리에서 가져옴)
|
||||
export interface ExternalConnection {
|
||||
id: string;
|
||||
name: string; // 사용자 지정 이름 (표시용)
|
||||
type: "postgresql" | "mysql" | "mssql" | "oracle";
|
||||
// 나머지 정보는 외부 커넥션 관리에서만 관리
|
||||
}
|
||||
|
||||
// 차트 설정 확장
|
||||
export interface ChartConfig {
|
||||
// 축 매핑
|
||||
xAxis: string; // X축 필드명
|
||||
yAxis: string | string[]; // Y축 필드명 (다중 가능)
|
||||
|
||||
// 데이터 처리
|
||||
groupBy?: string; // 그룹핑 필드
|
||||
aggregation?: "sum" | "avg" | "count" | "max" | "min";
|
||||
sortBy?: string; // 정렬 기준 필드
|
||||
sortOrder?: "asc" | "desc"; // 정렬 순서
|
||||
limit?: number; // 데이터 개수 제한
|
||||
|
||||
// 스타일
|
||||
colors?: string[]; // 차트 색상 팔레트
|
||||
title?: string; // 차트 제목
|
||||
showLegend?: boolean; // 범례 표시
|
||||
legendPosition?: "top" | "bottom" | "left" | "right"; // 범례 위치
|
||||
|
||||
// 축 설정
|
||||
xAxisLabel?: string; // X축 라벨
|
||||
yAxisLabel?: string; // Y축 라벨
|
||||
showGrid?: boolean; // 그리드 표시
|
||||
|
||||
// 애니메이션
|
||||
enableAnimation?: boolean; // 애니메이션 활성화
|
||||
animationDuration?: number; // 애니메이션 시간 (ms)
|
||||
|
||||
// 툴팁
|
||||
showTooltip?: boolean; // 툴팁 표시
|
||||
tooltipFormat?: string; // 툴팁 포맷 (템플릿)
|
||||
|
||||
// 차트별 특수 설정
|
||||
barOrientation?: "vertical" | "horizontal"; // 막대 방향
|
||||
lineStyle?: "smooth" | "straight"; // 선 스타일
|
||||
areaOpacity?: number; // 영역 투명도
|
||||
pieInnerRadius?: number; // 도넛 차트 내부 반지름 (0-1)
|
||||
stackMode?: "normal" | "percent"; // 누적 모드
|
||||
}
|
||||
|
||||
// API 응답 구조
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 차트 데이터 (변환 후)
|
||||
export interface ChartData {
|
||||
labels: string[]; // X축 레이블
|
||||
datasets: ChartDataset[]; // Y축 데이터셋 (다중 시리즈)
|
||||
}
|
||||
|
||||
export interface ChartDataset {
|
||||
label: string; // 시리즈 이름
|
||||
data: number[]; // 데이터 값
|
||||
color?: string; // 색상
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 구현 단계
|
||||
|
||||
### Phase 1: 데이터 소스 설정 UI (4-5시간)
|
||||
|
||||
#### Step 1.1: 데이터 소스 선택기
|
||||
|
||||
- [x] `DataSourceSelector.tsx` 생성
|
||||
- [x] DB vs API 선택 라디오 버튼
|
||||
- [x] 선택에 따라 하위 UI 동적 렌더링
|
||||
- [x] 상태 관리 (현재 선택된 소스 타입)
|
||||
|
||||
#### Step 1.2: 데이터베이스 설정
|
||||
|
||||
- [x] `DatabaseConfig.tsx` 생성
|
||||
- [x] 현재 DB / 외부 DB 선택 라디오 버튼
|
||||
- [x] 외부 DB 선택 시:
|
||||
- **기존 외부 커넥션 관리에서 등록된 커넥션 목록 불러오기**
|
||||
- 드롭다운으로 커넥션 선택 (ID, 이름, 타입 표시)
|
||||
- "외부 커넥션 관리로 이동" 링크 제공
|
||||
- 선택된 커넥션 정보 표시 (읽기 전용)
|
||||
- [x] SQL 에디터 통합 (기존 `QueryEditor` 재사용)
|
||||
- [x] 쿼리 테스트 버튼 (선택된 커넥션으로 실행)
|
||||
|
||||
#### Step 1.3: REST API 설정
|
||||
|
||||
- [x] `ApiConfig.tsx` 생성
|
||||
- [x] API 엔드포인트 URL 입력
|
||||
- [x] HTTP 메서드: GET 고정 (UI에서 표시만)
|
||||
- [x] URL 쿼리 파라미터 추가 UI (키-값 쌍)
|
||||
- 동적 파라미터 추가/제거 버튼
|
||||
- 예시: `?category=electronics&limit=10`
|
||||
- [x] 헤더 추가 UI (키-값 쌍)
|
||||
- Authorization 헤더 빠른 입력
|
||||
- 일반적인 헤더 템플릿 제공
|
||||
- [x] JSON Path 설정 (데이터 추출 경로)
|
||||
- 예시: `data.results`, `items`, `response.data`
|
||||
- [x] 테스트 요청 버튼
|
||||
- [x] 응답 미리보기 (JSON 구조 표시)
|
||||
|
||||
#### Step 1.4: 데이터 소스 유틸리티
|
||||
|
||||
- [x] `dataSourceUtils.ts` 생성
|
||||
- [x] DB 커넥션 검증 함수
|
||||
- [x] API 요청 실행 함수
|
||||
- [x] JSON Path 파싱 함수
|
||||
- [x] 데이터 정규화 함수 (DB/API 결과를 통일된 형식으로)
|
||||
|
||||
### Phase 2: 서버 측 API 구현 (1-2시간) ✅ 대부분 구현 완료
|
||||
|
||||
#### Step 2.1: 외부 커넥션 목록 조회 API ✅ 구현 완료
|
||||
|
||||
- [x] `GET /api/external-db-connections` - 기존 외부 커넥션 관리의 커넥션 목록 조회
|
||||
- [x] 프론트엔드 API: `ExternalDbConnectionAPI.getConnections({ is_active: 'Y' })`
|
||||
- [x] 응답: `{ id, connection_name, db_type, ... }`
|
||||
- [x] 인증된 사용자만 접근 가능
|
||||
- [x] **이미 구현되어 있음!**
|
||||
|
||||
#### Step 2.2: 쿼리 실행 API ✅ 외부 DB 완료, 현재 DB 확인 필요
|
||||
|
||||
**외부 DB 쿼리 실행 ✅ 구현 완료**
|
||||
|
||||
- [x] `POST /api/external-db-connections/:id/execute` - 외부 DB 쿼리 실행
|
||||
- [x] 프론트엔드 API: `ExternalDbConnectionAPI.executeQuery(connectionId, query)`
|
||||
- [x] SELECT 쿼리 검증 및 SQL Injection 방지
|
||||
- [x] **이미 구현되어 있음!**
|
||||
|
||||
**현재 DB 쿼리 실행 - 확인 필요**
|
||||
|
||||
- [ ] `POST /api/dashboards/execute-query` - 현재 DB 쿼리 실행 (이미 있는지 확인 필요)
|
||||
- [ ] SELECT 쿼리 검증 (정규식 + SQL 파서)
|
||||
- [ ] SQL Injection 방지
|
||||
- [ ] 쿼리 타임아웃 설정
|
||||
- [ ] 결과 행 수 제한 (최대 1000행)
|
||||
- [ ] 에러 핸들링 및 로깅
|
||||
|
||||
#### Step 2.3: REST API 프록시 ❌ 불필요 (CORS 허용된 Open API 사용)
|
||||
|
||||
- [x] ~~GET /api/dashboards/fetch-api~~ - 불필요 (프론트엔드에서 직접 호출)
|
||||
- [x] Open API는 CORS를 허용하므로 프록시 없이 직접 호출 가능
|
||||
- [x] `ApiConfig.tsx`에서 `fetch()` 직접 사용
|
||||
|
||||
### Phase 3: 차트 설정 UI 개선 (3-4시간)
|
||||
|
||||
#### Step 3.1: 축 매퍼
|
||||
|
||||
- [ ] `AxisMapper.tsx` 생성
|
||||
- [ ] X축 필드 선택 드롭다운
|
||||
- [ ] Y축 필드 다중 선택 (체크박스)
|
||||
- [ ] 데이터 타입 자동 감지 및 표시
|
||||
- [ ] 샘플 데이터 미리보기 (첫 3행)
|
||||
- [ ] 축 라벨 커스터마이징
|
||||
|
||||
#### Step 3.2: 스타일 설정
|
||||
|
||||
- [ ] `StyleConfig.tsx` 생성
|
||||
- [ ] 색상 팔레트 선택 (사전 정의 + 커스텀)
|
||||
- [ ] 범례 위치 선택
|
||||
- [ ] 그리드 표시/숨김
|
||||
- [ ] 애니메이션 설정
|
||||
- [ ] 차트별 특수 옵션
|
||||
- 막대 차트: 수평/수직
|
||||
- 선 차트: 부드러움 정도
|
||||
- 원 차트: 도넛 모드
|
||||
|
||||
#### Step 3.3: 실시간 미리보기
|
||||
|
||||
- [ ] `ChartPreview.tsx` 생성
|
||||
- [ ] 축소된 차트 미리보기 (300x200)
|
||||
- [ ] 설정 변경 시 실시간 업데이트
|
||||
- [ ] 로딩 상태 표시
|
||||
- [ ] 에러 표시
|
||||
|
||||
### Phase 4: D3 차트 컴포넌트 (6-8시간)
|
||||
|
||||
#### Step 4.1: 차트 렌더러 (공통)
|
||||
|
||||
- [ ] `ChartRenderer.tsx` 생성
|
||||
- [ ] 차트 타입에 따라 적절한 컴포넌트 렌더링
|
||||
- [ ] 데이터 정규화 및 변환
|
||||
- [ ] 공통 레이아웃 (제목, 범례)
|
||||
- [ ] 반응형 크기 조절
|
||||
- [ ] 에러 바운더리
|
||||
|
||||
#### Step 4.2: 막대 차트
|
||||
|
||||
- [ ] `BarChart.tsx` 생성
|
||||
- [ ] D3 스케일 설정 (x: 범주형, y: 선형)
|
||||
- [ ] 막대 렌더링 (rect 요소)
|
||||
- [ ] 축 렌더링 (d3-axis)
|
||||
- [ ] 툴팁 구현
|
||||
- [ ] 애니메이션 (높이 전환)
|
||||
- [ ] 수평/수직 모드 지원
|
||||
- [ ] 다중 시리즈 (그룹화)
|
||||
|
||||
#### Step 4.3: 선 차트
|
||||
|
||||
- [ ] `LineChart.tsx` 생성
|
||||
- [ ] D3 라인 제너레이터 (d3.line)
|
||||
- [ ] 부드러운 곡선 (d3.curveMonotoneX)
|
||||
- [ ] 데이터 포인트 표시 (circle)
|
||||
- [ ] 툴팁 구현
|
||||
- [ ] 애니메이션 (path 길이 전환)
|
||||
- [ ] 다중 시리즈 (여러 선)
|
||||
- [ ] 누락 데이터 처리
|
||||
|
||||
#### Step 4.4: 영역 차트
|
||||
|
||||
- [ ] `AreaChart.tsx` 생성
|
||||
- [ ] D3 영역 제너레이터 (d3.area)
|
||||
- [ ] 투명도 설정
|
||||
- [ ] 누적 모드 지원 (d3.stack)
|
||||
- [ ] 선 차트 기능 재사용
|
||||
- [ ] 애니메이션
|
||||
|
||||
#### Step 4.5: 원 차트
|
||||
|
||||
- [ ] `PieChart.tsx` 생성
|
||||
- [ ] D3 파이 레이아웃 (d3.pie)
|
||||
- [ ] 아크 제너레이터 (d3.arc)
|
||||
- [ ] 도넛 모드 (innerRadius)
|
||||
- [ ] 라벨 배치 (중심 또는 외부)
|
||||
- [ ] 툴팁 구현
|
||||
- [ ] 애니메이션 (회전 전환)
|
||||
- [ ] 퍼센트 표시
|
||||
|
||||
#### Step 4.6: 누적 막대 차트
|
||||
|
||||
- [ ] `StackedBarChart.tsx` 생성
|
||||
- [ ] D3 스택 레이아웃 (d3.stack)
|
||||
- [ ] 다중 시리즈 누적
|
||||
- [ ] 일반 누적 vs 퍼센트 모드
|
||||
- [ ] 막대 차트 로직 재사용
|
||||
- [ ] 범례 색상 매핑
|
||||
|
||||
#### Step 4.7: 혼합 차트
|
||||
|
||||
- [ ] `ComboChart.tsx` 생성
|
||||
- [ ] 막대 + 선 조합
|
||||
- [ ] 이중 Y축 (좌측: 막대, 우측: 선)
|
||||
- [ ] 스케일 독립 설정
|
||||
- [ ] 막대/선 차트 로직 결합
|
||||
- [ ] 복잡한 툴팁 (두 데이터 표시)
|
||||
|
||||
#### Step 4.8: 차트 유틸리티
|
||||
|
||||
- [ ] `chartUtils.ts` 생성
|
||||
- [ ] 데이터 변환 함수 (QueryResult → ChartData)
|
||||
- [ ] 날짜 파싱 및 포맷팅
|
||||
- [ ] 숫자 포맷팅 (천 단위 콤마, 소수점)
|
||||
- [ ] 색상 팔레트 정의
|
||||
- [ ] 반응형 크기 계산
|
||||
|
||||
#### Step 4.9: D3 헬퍼
|
||||
|
||||
- [ ] `d3Helpers.ts` 생성
|
||||
- [ ] 공통 스케일 생성
|
||||
- [ ] 축 생성 및 스타일링
|
||||
- [ ] 그리드 라인 추가
|
||||
- [ ] 툴팁 DOM 생성/제거
|
||||
- [ ] SVG 마진 계산
|
||||
|
||||
### Phase 5: 차트 통합 및 렌더링 (2-3시간)
|
||||
|
||||
#### Step 5.1: CanvasElement 통합
|
||||
|
||||
- [ ] `CanvasElement.tsx` 수정
|
||||
- [ ] 차트 요소 감지 (element.type === 'chart')
|
||||
- [ ] `ChartRenderer` 컴포넌트 임포트 및 렌더링
|
||||
- [ ] 데이터 로딩 상태 표시
|
||||
- [ ] 에러 상태 표시
|
||||
- [ ] 자동 새로고침 로직
|
||||
|
||||
#### Step 5.2: 데이터 페칭
|
||||
|
||||
- [ ] 차트 마운트 시 초기 데이터 로드
|
||||
- [ ] 자동 새로고침 타이머 설정
|
||||
- [ ] 수동 새로고침 버튼
|
||||
- [ ] 로딩/에러/성공 상태 관리
|
||||
- [ ] 캐싱 (선택적)
|
||||
|
||||
#### Step 5.3: ElementConfigModal 리팩토링
|
||||
|
||||
- [ ] 데이터 소스 선택 UI 통합
|
||||
- [ ] 3단계 플로우 구현
|
||||
1. 데이터 소스 선택 및 설정
|
||||
2. 데이터 가져오기 및 검증
|
||||
3. 축 매핑 및 스타일 설정
|
||||
- [ ] 진행 표시기 (스텝 인디케이터)
|
||||
- [ ] 뒤로/다음 버튼
|
||||
|
||||
### Phase 6: 테스트 및 최적화 (2-3시간)
|
||||
|
||||
#### Step 6.1: 기능 테스트
|
||||
|
||||
- [ ] 각 차트 타입 렌더링 확인
|
||||
- [ ] DB 쿼리 실행 및 차트 생성
|
||||
- [ ] API 호출 및 차트 생성
|
||||
- [ ] 다중 시리즈 차트 확인
|
||||
- [ ] 자동 새로고침 동작 확인
|
||||
- [ ] 에러 처리 확인
|
||||
|
||||
#### Step 6.2: UI/UX 개선
|
||||
|
||||
- [ ] 로딩 스피너 추가
|
||||
- [ ] 빈 데이터 상태 UI
|
||||
- [ ] 에러 메시지 개선
|
||||
- [ ] 툴팁 스타일링
|
||||
- [ ] 범례 스타일링
|
||||
- [ ] 반응형 레이아웃 확인
|
||||
|
||||
#### Step 6.3: 성능 최적화
|
||||
|
||||
- [ ] D3 렌더링 최적화 (불필요한 재렌더링 방지)
|
||||
- [ ] 대용량 데이터 처리 (샘플링, 페이징)
|
||||
- [ ] 메모이제이션 (useMemo, useCallback)
|
||||
- [ ] SVG 최적화
|
||||
- [ ] 차트 데이터 캐싱
|
||||
|
||||
---
|
||||
|
||||
## 🔒 보안 고려사항
|
||||
|
||||
### SQL Injection 방지
|
||||
|
||||
- 서버 측에서 쿼리 타입 엄격 검증 (SELECT만 허용)
|
||||
- 정규식 + SQL 파서 사용
|
||||
- Prepared Statement 사용 (파라미터 바인딩)
|
||||
- 위험한 키워드 차단 (DROP, DELETE, UPDATE, INSERT, EXEC 등)
|
||||
|
||||
### 외부 DB 커넥션 보안
|
||||
|
||||
- 기존 "외부 커넥션 관리"에서 보안 처리됨
|
||||
- 차트 시스템에서는 커넥션 ID만 사용
|
||||
- 민감 정보(비밀번호, 호스트 등)는 차트 설정에 노출하지 않음
|
||||
- 타임아웃 설정 (30초)
|
||||
|
||||
### API 보안
|
||||
|
||||
- CORS 정책 확인
|
||||
- 민감한 헤더 로깅 방지 (Authorization 등)
|
||||
- 요청 크기 제한
|
||||
- Rate Limiting (API 호출 빈도 제한)
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI/UX 개선 사항
|
||||
|
||||
### 설정 플로우
|
||||
|
||||
1. **데이터 소스 선택**
|
||||
- 큰 아이콘과 설명으로 DB vs API 선택
|
||||
- 각 방식의 장단점 안내
|
||||
|
||||
2. **데이터 구성**
|
||||
- DB: SQL 에디터 + 실행 버튼
|
||||
- API: URL, 메서드, 헤더, 본문 입력
|
||||
- 테스트 버튼으로 즉시 확인
|
||||
|
||||
3. **데이터 미리보기**
|
||||
- 쿼리/API 실행 결과를 테이블로 표시 (최대 10행)
|
||||
- 컬럼명과 샘플 데이터 표시
|
||||
|
||||
4. **차트 설정**
|
||||
- X/Y축 드래그 앤 드롭 매핑
|
||||
- 실시간 미리보기 (작은 차트)
|
||||
- 스타일 프리셋 선택
|
||||
|
||||
### 피드백 메시지
|
||||
|
||||
- ✅ 성공: "데이터를 성공적으로 불러왔습니다 (45행)"
|
||||
- ⚠️ 경고: "쿼리 실행이 오래 걸리고 있습니다"
|
||||
- ❌ 오류: "데이터베이스 연결에 실패했습니다: 잘못된 비밀번호"
|
||||
|
||||
### 로딩 상태
|
||||
|
||||
- 스켈레톤 UI (차트 윤곽)
|
||||
- 진행률 표시 (대용량 데이터)
|
||||
- 취소 버튼 (장시간 실행 쿼리)
|
||||
|
||||
---
|
||||
|
||||
## 📊 샘플 데이터 및 시나리오
|
||||
|
||||
### 시나리오 1: 월별 매출 추이 (DB 쿼리)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
TO_CHAR(order_date, 'YYYY-MM') as month,
|
||||
SUM(total_amount) as sales
|
||||
FROM orders
|
||||
WHERE order_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||
GROUP BY TO_CHAR(order_date, 'YYYY-MM')
|
||||
ORDER BY month;
|
||||
```
|
||||
|
||||
- **차트 타입**: Line Chart
|
||||
- **X축**: month
|
||||
- **Y축**: sales
|
||||
|
||||
### 시나리오 2: 제품 비교 (다중 시리즈)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
DATE_TRUNC('month', order_date) as month,
|
||||
SUM(CASE WHEN product_category = '갤럭시' THEN amount ELSE 0 END) as galaxy,
|
||||
SUM(CASE WHEN product_category = '아이폰' THEN amount ELSE 0 END) as iphone
|
||||
FROM orders
|
||||
WHERE order_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||
GROUP BY DATE_TRUNC('month', order_date)
|
||||
ORDER BY month;
|
||||
```
|
||||
|
||||
- **차트 타입**: Combo Chart (Bar + Line)
|
||||
- **X축**: month
|
||||
- **Y축**: [galaxy, iphone] (다중)
|
||||
|
||||
### 시나리오 3: 카테고리별 매출 (원 차트)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
category,
|
||||
SUM(amount) as total
|
||||
FROM sales
|
||||
WHERE sale_date >= CURRENT_DATE - INTERVAL '1 month'
|
||||
GROUP BY category
|
||||
ORDER BY total DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
- **차트 타입**: Pie Chart (Donut)
|
||||
- **X축**: category
|
||||
- **Y축**: total
|
||||
|
||||
### 시나리오 4: REST API (실시간 환율)
|
||||
|
||||
- **API**: `https://api.exchangerate-api.com/v4/latest/USD`
|
||||
- **JSON Path**: `rates`
|
||||
- **변환**: Object를 배열로 변환 (통화: 환율)
|
||||
- **차트 타입**: Bar Chart
|
||||
- **X축**: 통화 코드 (KRW, JPY, EUR 등)
|
||||
- **Y축**: 환율
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료 기준
|
||||
|
||||
### Phase 1: 데이터 소스 설정
|
||||
|
||||
- [x] DB 커넥션 설정 UI 작동
|
||||
- [x] 외부 DB 커넥션 저장 및 불러오기
|
||||
- [x] API 설정 UI 작동
|
||||
- [x] 테스트 버튼으로 즉시 확인 가능
|
||||
|
||||
### Phase 2: 서버 API
|
||||
|
||||
- [x] 외부 DB 커넥션 CRUD API 작동
|
||||
- [x] 쿼리 실행 API (현재/외부 DB)
|
||||
- [x] SELECT 쿼리 검증 및 SQL Injection 방지
|
||||
- [x] API 프록시 작동
|
||||
|
||||
### Phase 3: 차트 설정 UI
|
||||
|
||||
- [x] 축 매핑 UI 직관적
|
||||
- [x] 다중 Y축 선택 가능
|
||||
- [x] 스타일 설정 UI 작동
|
||||
- [x] 실시간 미리보기 표시
|
||||
|
||||
### Phase 4: D3 차트
|
||||
|
||||
- [x] 6가지 차트 타입 모두 렌더링
|
||||
- [x] 툴팁 표시
|
||||
- [x] 애니메이션 부드러움
|
||||
- [x] 반응형 크기 조절
|
||||
- [x] 다중 시리즈 지원
|
||||
|
||||
### Phase 5: 통합
|
||||
|
||||
- [x] 캔버스에서 차트 표시
|
||||
- [x] 자동 새로고침 작동
|
||||
- [x] 설정 모달 3단계 플로우 완료
|
||||
- [x] 데이터 로딩/에러 상태 표시
|
||||
|
||||
### Phase 6: 테스트
|
||||
|
||||
- [x] 모든 차트 타입 정상 작동
|
||||
- [x] DB/API 데이터 소스 모두 작동
|
||||
- [x] 에러 처리 적절
|
||||
- [x] 성능 이슈 없음 (1000행 데이터)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 향후 확장 계획
|
||||
|
||||
- **실시간 스트리밍**: WebSocket 데이터 소스 추가
|
||||
- **고급 차트**: Scatter Plot, Heatmap, Radar Chart
|
||||
- **데이터 변환**: 필터링, 정렬, 계산 필드 추가
|
||||
- **차트 상호작용**: 클릭/드래그로 데이터 필터링
|
||||
- **내보내기**: PNG, SVG, PDF 저장
|
||||
- **템플릿**: 사전 정의된 차트 템플릿 (업종별)
|
||||
|
||||
---
|
||||
|
||||
## 📅 예상 일정
|
||||
|
||||
- **Phase 1**: 1일 (데이터 소스 UI)
|
||||
- **Phase 2**: 0.5일 (서버 API) - 기존 외부 커넥션 관리 활용으로 단축
|
||||
- **Phase 3**: 1일 (차트 설정 UI)
|
||||
- **Phase 4**: 2일 (D3 차트 컴포넌트)
|
||||
- **Phase 5**: 0.5일 (통합)
|
||||
- **Phase 6**: 0.5일 (테스트)
|
||||
|
||||
**총 예상 시간**: 5.5일 (44시간)
|
||||
|
||||
---
|
||||
|
||||
**구현 시작일**: 2025-10-14
|
||||
**목표 완료일**: 2025-10-20
|
||||
**현재 진행률**: 90% (Phase 1-5 완료, D3 차트 추가 구현 ✅)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 다음 단계
|
||||
|
||||
1. ~~Phase 1 완료: 데이터 소스 UI 구현~~ ✅
|
||||
2. ~~Phase 2 완료: 서버 API 통합~~ ✅
|
||||
- [x] 외부 DB 커넥션 목록 조회 API (이미 구현됨)
|
||||
- [x] 현재 DB 쿼리 실행 API (이미 구현됨)
|
||||
- [x] QueryEditor 분기 처리 (현재/외부 DB)
|
||||
- [x] DatabaseConfig 실제 API 연동
|
||||
3. **Phase 3 시작**: 차트 설정 UI 개선
|
||||
- [ ] 축 매퍼 및 스타일 설정 UI
|
||||
- [ ] 실시간 미리보기
|
||||
4. **Phase 4**: D3.js 라이브러리 설치 및 차트 컴포넌트 구현
|
||||
5. **Phase 5**: CanvasElement 통합 및 데이터 페칭
|
||||
|
||||
---
|
||||
|
||||
## 📊 Phase 2 최종 정리
|
||||
|
||||
### ✅ 구현 완료된 API 통합
|
||||
|
||||
1. **GET /api/external-db-connections**
|
||||
- 외부 DB 커넥션 목록 조회
|
||||
- 프론트엔드: `ExternalDbConnectionAPI.getConnections({ is_active: 'Y' })`
|
||||
- 통합: `DatabaseConfig.tsx`
|
||||
|
||||
2. **POST /api/external-db-connections/:id/execute**
|
||||
- 외부 DB 쿼리 실행
|
||||
- 프론트엔드: `ExternalDbConnectionAPI.executeQuery(connectionId, query)`
|
||||
- 통합: `QueryEditor.tsx`
|
||||
|
||||
3. **POST /api/dashboards/execute-query**
|
||||
- 현재 DB 쿼리 실행
|
||||
- 프론트엔드: `dashboardApi.executeQuery(query)`
|
||||
- 통합: `QueryEditor.tsx`
|
||||
|
||||
### ❌ 불필요 (제거됨)
|
||||
|
||||
4. ~~**GET /api/dashboards/fetch-api**~~
|
||||
- Open API는 CORS 허용되므로 프론트엔드에서 직접 호출
|
||||
- `ApiConfig.tsx`에서 `fetch()` 직접 사용
|
||||
|
||||
---
|
||||
|
||||
## 🎉 전체 구현 완료 요약
|
||||
|
||||
### Phase 1: 데이터 소스 UI ✅
|
||||
|
||||
- `DataSourceSelector`: DB vs API 선택 UI
|
||||
- `DatabaseConfig`: 현재 DB / 외부 DB 선택 및 API 연동
|
||||
- `ApiConfig`: REST API 설정
|
||||
- `dataSourceUtils`: 유틸리티 함수
|
||||
|
||||
### Phase 2: 서버 API 통합 ✅
|
||||
|
||||
- `GET /api/external-db-connections`: 외부 커넥션 목록 조회
|
||||
- `POST /api/external-db-connections/:id/execute`: 외부 DB 쿼리 실행
|
||||
- `POST /api/dashboards/execute-query`: 현재 DB 쿼리 실행
|
||||
- **QueryEditor**: 현재 DB / 외부 DB 분기 처리 완료
|
||||
|
||||
### Phase 3: 차트 설정 UI ✅
|
||||
|
||||
- `ChartConfigPanel`: X/Y축 매핑, 스타일 설정, 색상 팔레트
|
||||
- 다중 Y축 선택 지원
|
||||
- 설정 미리보기
|
||||
|
||||
### Phase 4: D3 차트 컴포넌트 ✅
|
||||
|
||||
- **D3 차트 구현** (6종):
|
||||
- `BarChart.tsx`: 막대 차트
|
||||
- `LineChart.tsx`: 선 차트
|
||||
- `AreaChart.tsx`: 영역 차트
|
||||
- `PieChart.tsx`: 원/도넛 차트
|
||||
- `StackedBarChart.tsx`: 누적 막대 차트
|
||||
- `Chart.tsx`: 통합 컴포넌트
|
||||
- **Recharts 완전 제거**: D3로 완전히 대체
|
||||
|
||||
### Phase 5: 통합 ✅
|
||||
|
||||
- `CanvasElement`: 차트 렌더링 통합 완료
|
||||
- `ChartRenderer`: D3 기반으로 완전히 교체
|
||||
- `chartDataTransform.ts`: 데이터 변환 유틸리티
|
||||
- 데이터 페칭 및 자동 새로고침
|
||||
|
|
@ -73,6 +73,7 @@ import { ClockWidget } from "./widgets/ClockWidget";
|
|||
import { CalendarWidget } from "./widgets/CalendarWidget";
|
||||
// 기사 관리 위젯 임포트
|
||||
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
|
||||
import { ListWidget } from "./widgets/ListWidget";
|
||||
|
||||
interface CanvasElementProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -292,26 +293,51 @@ export function CanvasElement({
|
|||
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
// console.log('🔄 쿼리 실행 시작:', element.dataSource.query);
|
||||
let result;
|
||||
|
||||
// 실제 API 호출
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
// 외부 DB vs 현재 DB 분기
|
||||
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
||||
// 외부 DB
|
||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(element.dataSource.externalConnectionId),
|
||||
element.dataSource.query,
|
||||
);
|
||||
|
||||
// console.log('✅ 쿼리 실행 결과:', result);
|
||||
if (!externalResult.success) {
|
||||
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
||||
}
|
||||
|
||||
setChartData({
|
||||
columns: result.columns || [],
|
||||
rows: result.rows || [],
|
||||
totalRows: result.rowCount || 0,
|
||||
executionTime: 0,
|
||||
});
|
||||
setChartData({
|
||||
columns: externalResult.data?.[0] ? Object.keys(externalResult.data[0]) : [],
|
||||
rows: externalResult.data || [],
|
||||
totalRows: externalResult.data?.length || 0,
|
||||
executionTime: 0,
|
||||
});
|
||||
} else {
|
||||
// 현재 DB
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
|
||||
setChartData({
|
||||
columns: result.columns || [],
|
||||
rows: result.rows || [],
|
||||
totalRows: result.rowCount || 0,
|
||||
executionTime: 0,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Chart data loading error:", error);
|
||||
setChartData(null);
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
}, [element.dataSource?.query, element.type]);
|
||||
}, [
|
||||
element.dataSource?.query,
|
||||
element.dataSource?.connectionType,
|
||||
element.dataSource?.externalConnectionId,
|
||||
element.type,
|
||||
]);
|
||||
|
||||
// 컴포넌트 마운트 시 및 쿼리 변경 시 데이터 로딩
|
||||
useEffect(() => {
|
||||
|
|
@ -358,6 +384,8 @@ export function CanvasElement({
|
|||
return "bg-gradient-to-br from-indigo-400 to-purple-600";
|
||||
case "driver-management":
|
||||
return "bg-gradient-to-br from-blue-400 to-indigo-600";
|
||||
case "list":
|
||||
return "bg-gradient-to-br from-cyan-400 to-blue-600";
|
||||
default:
|
||||
return "bg-gray-200";
|
||||
}
|
||||
|
|
@ -503,6 +531,16 @@ export function CanvasElement({
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "list" ? (
|
||||
// 리스트 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<ListWidget
|
||||
element={element}
|
||||
onConfigUpdate={(newConfig) => {
|
||||
onUpdate(element.id, { listConfig: newConfig as any });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "todo" ? (
|
||||
// To-Do 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,23 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { ChartConfig, QueryResult } from './types';
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { ChartConfig, QueryResult } from "./types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { TrendingUp, AlertCircle } from "lucide-react";
|
||||
|
||||
interface ChartConfigPanelProps {
|
||||
config?: ChartConfig;
|
||||
queryResult?: QueryResult;
|
||||
onConfigChange: (config: ChartConfig) => void;
|
||||
chartType?: string;
|
||||
dataSourceType?: "database" | "api"; // 데이터 소스 타입
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -15,186 +26,340 @@ interface ChartConfigPanelProps {
|
|||
* - 차트 스타일 설정
|
||||
* - 실시간 미리보기
|
||||
*/
|
||||
export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartConfigPanelProps) {
|
||||
export function ChartConfigPanel({
|
||||
config,
|
||||
queryResult,
|
||||
onConfigChange,
|
||||
chartType,
|
||||
dataSourceType,
|
||||
}: ChartConfigPanelProps) {
|
||||
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
||||
|
||||
// 설정 업데이트
|
||||
const updateConfig = useCallback((updates: Partial<ChartConfig>) => {
|
||||
const newConfig = { ...currentConfig, ...updates };
|
||||
setCurrentConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}, [currentConfig, onConfigChange]);
|
||||
// 원형/도넛 차트 또는 REST API는 Y축이 필수가 아님
|
||||
const isPieChart = chartType === "pie" || chartType === "donut";
|
||||
const isApiSource = dataSourceType === "api";
|
||||
|
||||
// 사용 가능한 컬럼 목록
|
||||
// 설정 업데이트
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<ChartConfig>) => {
|
||||
const newConfig = { ...currentConfig, ...updates };
|
||||
setCurrentConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
},
|
||||
[currentConfig, onConfigChange],
|
||||
);
|
||||
|
||||
// 사용 가능한 컬럼 목록 및 타입 정보
|
||||
const availableColumns = queryResult?.columns || [];
|
||||
const columnTypes = queryResult?.columnTypes || {};
|
||||
const sampleData = queryResult?.rows?.[0] || {};
|
||||
|
||||
// 차트에 사용 가능한 컬럼 필터링
|
||||
const simpleColumns = availableColumns.filter((col) => {
|
||||
const type = columnTypes[col];
|
||||
// number, string, boolean만 허용 (object, array는 제외)
|
||||
return !type || type === "number" || type === "string" || type === "boolean";
|
||||
});
|
||||
|
||||
// 숫자 타입 컬럼만 필터링 (Y축용)
|
||||
const numericColumns = availableColumns.filter((col) => columnTypes[col] === "number");
|
||||
|
||||
// 복잡한 타입의 컬럼 (경고 표시용)
|
||||
const complexColumns = availableColumns.filter((col) => {
|
||||
const type = columnTypes[col];
|
||||
return type === "object" || type === "array";
|
||||
});
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 데이터 필드 매핑 */}
|
||||
{queryResult && (
|
||||
<>
|
||||
{/* API 응답 미리보기 */}
|
||||
{queryResult.rows && queryResult.rows.length > 0 && (
|
||||
<Card className="border-blue-200 bg-blue-50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-blue-600" />
|
||||
<h4 className="font-semibold text-blue-900">📋 API 응답 데이터 미리보기</h4>
|
||||
</div>
|
||||
<div className="rounded bg-white p-3 text-xs">
|
||||
<div className="mb-2 text-gray-600">총 {queryResult.totalRows}개 데이터 중 첫 번째 행:</div>
|
||||
<pre className="overflow-x-auto text-gray-800">{JSON.stringify(sampleData, null, 2)}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 복잡한 타입 경고 */}
|
||||
{complexColumns.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="font-semibold">⚠️ 차트에 사용할 수 없는 컬럼 감지</div>
|
||||
<div className="mt-1 text-sm">
|
||||
다음 컬럼은 객체 또는 배열 타입이라서 차트 축으로 선택할 수 없습니다:
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{complexColumns.map((col) => (
|
||||
<Badge key={col} variant="outline" className="bg-red-50">
|
||||
{col} ({columnTypes[col]})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-600">
|
||||
💡 <strong>해결 방법:</strong> JSON Path를 사용하여 중첩된 객체 내부의 값을 직접 추출하세요.
|
||||
<br />
|
||||
예: <code className="rounded bg-gray-100 px-1">main</code> 또는{" "}
|
||||
<code className="rounded bg-gray-100 px-1">data.items</code>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 차트 제목 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">차트 제목</label>
|
||||
<input
|
||||
<Label>차트 제목</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={currentConfig.title || ''}
|
||||
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>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* X축 설정 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<Label>
|
||||
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>
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={currentConfig.xAxis || undefined} onValueChange={(value) => updateConfig({ xAxis: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{simpleColumns.map((col) => {
|
||||
const preview = sampleData[col];
|
||||
const previewText =
|
||||
preview !== undefined && preview !== null
|
||||
? typeof preview === "object"
|
||||
? JSON.stringify(preview).substring(0, 30)
|
||||
: String(preview).substring(0, 30)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<SelectItem key={col} value={col}>
|
||||
{col}
|
||||
{previewText && <span className="ml-2 text-xs text-gray-500">(예: {previewText})</span>}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{simpleColumns.length === 0 && (
|
||||
<p className="text-xs text-red-500">⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Y축 설정 (다중 선택 가능) */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<Label>
|
||||
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>
|
||||
{!isPieChart && !isApiSource && <span className="ml-1 text-red-500">*</span>}
|
||||
{(isPieChart || isApiSource) && (
|
||||
<span className="ml-2 text-xs text-gray-500">(선택사항 - 그룹핑+집계 사용 가능)</span>
|
||||
)}
|
||||
</Label>
|
||||
<Card className="max-h-60 overflow-y-auto p-3">
|
||||
<div className="space-y-2">
|
||||
{/* 숫자 타입 우선 표시 */}
|
||||
{numericColumns.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2 text-xs font-medium text-green-700">✅ 숫자 타입 (권장)</div>
|
||||
{numericColumns.map((col) => {
|
||||
const isSelected = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis.includes(col)
|
||||
: currentConfig.yAxis === col;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col}
|
||||
className="flex items-center gap-2 rounded border-l-2 border-green-500 bg-green-50 p-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentYAxis = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis
|
||||
: currentConfig.yAxis
|
||||
? [currentConfig.yAxis]
|
||||
: [];
|
||||
|
||||
let newYAxis: string | string[];
|
||||
if (checked) {
|
||||
newYAxis = [...currentYAxis, col];
|
||||
} else {
|
||||
newYAxis = currentYAxis.filter((c) => c !== col);
|
||||
}
|
||||
|
||||
if (newYAxis.length === 1) {
|
||||
newYAxis = newYAxis[0];
|
||||
}
|
||||
|
||||
updateConfig({ yAxis: newYAxis });
|
||||
}}
|
||||
/>
|
||||
<Label className="flex-1 cursor-pointer text-sm font-normal">
|
||||
<span className="font-medium">{col}</span>
|
||||
{sampleData[col] !== undefined && (
|
||||
<span className="ml-2 text-xs text-gray-600">(예: {sampleData[col]})</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 기타 간단한 타입 */}
|
||||
{simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && (
|
||||
<>
|
||||
{numericColumns.length > 0 && <div className="my-2 border-t"></div>}
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">📝 기타 타입</div>
|
||||
{simpleColumns
|
||||
.filter((col) => !numericColumns.includes(col))
|
||||
.map((col) => {
|
||||
const isSelected = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis.includes(col)
|
||||
: currentConfig.yAxis === col;
|
||||
|
||||
return (
|
||||
<div key={col} className="flex items-center gap-2 rounded p-2 hover:bg-gray-50">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentYAxis = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis
|
||||
: currentConfig.yAxis
|
||||
? [currentConfig.yAxis]
|
||||
: [];
|
||||
|
||||
let newYAxis: string | string[];
|
||||
if (checked) {
|
||||
newYAxis = [...currentYAxis, col];
|
||||
} else {
|
||||
newYAxis = currentYAxis.filter((c) => c !== col);
|
||||
}
|
||||
|
||||
if (newYAxis.length === 1) {
|
||||
newYAxis = newYAxis[0];
|
||||
}
|
||||
|
||||
updateConfig({ yAxis: newYAxis });
|
||||
}}
|
||||
/>
|
||||
<Label className="flex-1 cursor-pointer text-sm font-normal">
|
||||
{col}
|
||||
{sampleData[col] !== undefined && (
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
(예: {String(sampleData[col]).substring(0, 30)})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
{simpleColumns.length === 0 && (
|
||||
<p className="text-xs text-red-500">⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 집계 함수 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<Label>
|
||||
집계 함수
|
||||
<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"
|
||||
<span className="ml-2 text-xs text-gray-500">(데이터 처리 방식)</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={currentConfig.aggregation || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
aggregation: value === "none" ? undefined : (value as "sum" | "avg" | "count" | "max" | "min"),
|
||||
})
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음 - SQL에서 집계됨</SelectItem>
|
||||
<SelectItem value="sum">합계 (SUM) - 모든 값을 더함</SelectItem>
|
||||
<SelectItem value="avg">평균 (AVG) - 평균값 계산</SelectItem>
|
||||
<SelectItem value="count">개수 (COUNT) - 데이터 개수</SelectItem>
|
||||
<SelectItem value="max">최대값 (MAX) - 가장 큰 값</SelectItem>
|
||||
<SelectItem value="min">최소값 (MIN) - 가장 작은 값</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500">
|
||||
💡 그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 그룹핑 필드 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<Label>
|
||||
그룹핑 필드 (선택사항)
|
||||
</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"
|
||||
<span className="ml-2 text-xs text-gray-500">(같은 값끼리 묶어서 집계)</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={currentConfig.groupBy || undefined}
|
||||
onValueChange={(value) => updateConfig({ groupBy: value })}
|
||||
>
|
||||
<option value="">없음</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="없음" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col} value={col}>
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 차트 색상 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">차트 색상</label>
|
||||
<Label>차트 색상</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'], // 따뜻한
|
||||
["#3B82F6", "#EF4444", "#10B981", "#F59E0B"], // 기본
|
||||
["#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"], // 밝은
|
||||
["#1F2937", "#374151", "#6B7280", "#9CA3AF"], // 회색
|
||||
["#DC2626", "#EA580C", "#CA8A04", "#65A30D"], // 따뜻한
|
||||
].map((colorSet, setIdx) => (
|
||||
<button
|
||||
key={setIdx}
|
||||
type="button"
|
||||
onClick={() => updateConfig({ colors: colorSet })}
|
||||
className={`
|
||||
h-8 rounded border-2 flex
|
||||
${JSON.stringify(currentConfig.colors) === JSON.stringify(colorSet)
|
||||
? 'border-gray-800' : 'border-gray-300'}
|
||||
`}
|
||||
className={`flex h-8 rounded border-2 transition-colors ${
|
||||
JSON.stringify(currentConfig.colors) === JSON.stringify(colorSet)
|
||||
? "border-gray-800"
|
||||
: "border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
{colorSet.map((color, idx) => (
|
||||
<div
|
||||
|
|
@ -210,50 +375,75 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
|
|||
|
||||
{/* 범례 표시 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
id="showLegend"
|
||||
checked={currentConfig.showLegend !== false}
|
||||
onChange={(e) => updateConfig({ showLegend: e.target.checked })}
|
||||
className="rounded"
|
||||
onCheckedChange={(checked) => updateConfig({ showLegend: checked as boolean })}
|
||||
/>
|
||||
<label htmlFor="showLegend" className="text-sm text-gray-700">
|
||||
<Label htmlFor="showLegend" className="cursor-pointer font-normal">
|
||||
범례 표시
|
||||
</label>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 설정 미리보기 */}
|
||||
<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-muted-foreground 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 || '미설정'
|
||||
}
|
||||
<Card className="bg-gray-50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
설정 미리보기
|
||||
</div>
|
||||
<div className="space-y-2 text-xs text-gray-600">
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">X축:</span>
|
||||
<span>{currentConfig.xAxis || "미설정"}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">Y축:</span>
|
||||
<span>
|
||||
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 0
|
||||
? `${currentConfig.yAxis.length}개 (${currentConfig.yAxis.join(", ")})`
|
||||
: currentConfig.yAxis || "미설정"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">집계:</span>
|
||||
<span>{currentConfig.aggregation || "없음"}</span>
|
||||
</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-primary mt-2">
|
||||
✨ 다중 시리즈 차트가 생성됩니다!
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">그룹핑:</span>
|
||||
<span>{currentConfig.groupBy}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">데이터 행 수:</span>
|
||||
<Badge variant="secondary">{queryResult.rows.length}개</Badge>
|
||||
</div>
|
||||
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 1 && (
|
||||
<div className="mt-2 text-blue-600">✨ 다중 시리즈 차트가 생성됩니다!</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 필수 필드 확인 */}
|
||||
{(!currentConfig.xAxis || !currentConfig.yAxis) && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<div className="text-red-800 text-sm">
|
||||
⚠️ X축과 Y축을 모두 설정해야 차트가 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
{!currentConfig.xAxis && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>X축은 필수입니다.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!isPieChart && !isApiSource && !currentConfig.yAxis && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Y축을 설정해야 차트가 표시됩니다.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{(isPieChart || isApiSource) && !currentConfig.yAxis && !currentConfig.aggregation && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Y축 또는 집계 함수(COUNT 등)를 설정해야 차트가 표시됩니다.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DashboardCanvas } from "./DashboardCanvas";
|
||||
import { DashboardSidebar } from "./DashboardSidebar";
|
||||
import { DashboardToolbar } from "./DashboardToolbar";
|
||||
import { ElementConfigModal } from "./ElementConfigModal";
|
||||
import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
|
||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
||||
import { GRID_CONFIG } from "./gridUtils";
|
||||
|
||||
|
|
@ -16,6 +18,7 @@ import { GRID_CONFIG } from "./gridUtils";
|
|||
* - 레이아웃 저장/불러오기 기능
|
||||
*/
|
||||
export default function DashboardDesigner() {
|
||||
const router = useRouter();
|
||||
const [elements, setElements] = useState<DashboardElement[]>([]);
|
||||
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
||||
const [elementCounter, setElementCounter] = useState(0);
|
||||
|
|
@ -154,6 +157,16 @@ export default function DashboardDesigner() {
|
|||
[updateElement],
|
||||
);
|
||||
|
||||
// 리스트 위젯 설정 저장 (Partial 업데이트)
|
||||
const saveListWidgetConfig = useCallback(
|
||||
(updates: Partial<DashboardElement>) => {
|
||||
if (configModalElement) {
|
||||
updateElement(configModalElement.id, updates);
|
||||
}
|
||||
},
|
||||
[configModalElement, updateElement],
|
||||
);
|
||||
|
||||
// 레이아웃 저장
|
||||
const saveLayout = useCallback(async () => {
|
||||
if (elements.length === 0) {
|
||||
|
|
@ -181,15 +194,14 @@ export default function DashboardDesigner() {
|
|||
|
||||
if (dashboardId) {
|
||||
// 기존 대시보드 업데이트
|
||||
// console.log('🔄 대시보드 업데이트:', dashboardId);
|
||||
savedDashboard = await dashboardApi.updateDashboard(dashboardId, {
|
||||
elements: elementsData,
|
||||
});
|
||||
|
||||
alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`);
|
||||
|
||||
// 뷰어 페이지로 이동
|
||||
window.location.href = `/dashboard/${savedDashboard.id}`;
|
||||
// Next.js 라우터로 뷰어 페이지 이동
|
||||
router.push(`/dashboard/${savedDashboard.id}`);
|
||||
} else {
|
||||
// 새 대시보드 생성
|
||||
const title = prompt("대시보드 제목을 입력하세요:", "새 대시보드");
|
||||
|
|
@ -206,20 +218,17 @@ export default function DashboardDesigner() {
|
|||
|
||||
savedDashboard = await dashboardApi.createDashboard(dashboardData);
|
||||
|
||||
// console.log('✅ 대시보드 생성 완료:', savedDashboard);
|
||||
|
||||
const viewDashboard = confirm(`대시보드 "${title}"이 저장되었습니다!\n\n지금 확인해보시겠습니까?`);
|
||||
if (viewDashboard) {
|
||||
window.location.href = `/dashboard/${savedDashboard.id}`;
|
||||
// Next.js 라우터로 뷰어 페이지 이동
|
||||
router.push(`/dashboard/${savedDashboard.id}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error('❌ 저장 오류:', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
|
||||
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`);
|
||||
}
|
||||
}, [elements, dashboardId]);
|
||||
}, [elements, dashboardId, router]);
|
||||
|
||||
// 로딩 중이면 로딩 화면 표시
|
||||
if (isLoading) {
|
||||
|
|
@ -273,12 +282,23 @@ export default function DashboardDesigner() {
|
|||
|
||||
{/* 요소 설정 모달 */}
|
||||
{configModalElement && (
|
||||
<ElementConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveElementConfig}
|
||||
/>
|
||||
<>
|
||||
{configModalElement.type === "widget" && configModalElement.subtype === "list" ? (
|
||||
<ListWidgetConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveListWidgetConfig}
|
||||
/>
|
||||
) : (
|
||||
<ElementConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveElementConfig}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -290,6 +310,8 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
|||
switch (subtype) {
|
||||
case "bar":
|
||||
return "📊 바 차트";
|
||||
case "horizontal-bar":
|
||||
return "📊 수평 바 차트";
|
||||
case "pie":
|
||||
return "🥧 원형 차트";
|
||||
case "line":
|
||||
|
|
@ -313,6 +335,8 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
|||
return "📅 달력 위젯";
|
||||
case "driver-management":
|
||||
return "🚚 기사 관리 위젯";
|
||||
case "list":
|
||||
return "📋 리스트 위젯";
|
||||
default:
|
||||
return "🔧 위젯";
|
||||
}
|
||||
|
|
@ -326,6 +350,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string {
|
|||
switch (subtype) {
|
||||
case "bar":
|
||||
return "바 차트가 여기에 표시됩니다";
|
||||
case "horizontal-bar":
|
||||
return "수평 바 차트가 여기에 표시됩니다";
|
||||
case "pie":
|
||||
return "원형 차트가 여기에 표시됩니다";
|
||||
case "line":
|
||||
|
|
@ -349,6 +375,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string {
|
|||
return "calendar";
|
||||
case "driver-management":
|
||||
return "driver-management";
|
||||
case "list":
|
||||
return "list-widget";
|
||||
default:
|
||||
return "위젯 내용이 여기에 표시됩니다";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,14 @@ export function DashboardSidebar() {
|
|||
onDragStart={handleDragStart}
|
||||
className="border-primary border-l-4"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📊"
|
||||
title="수평 바 차트"
|
||||
type="chart"
|
||||
subtype="horizontal-bar"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📚"
|
||||
title="누적 바 차트"
|
||||
|
|
@ -215,6 +223,14 @@ export function DashboardSidebar() {
|
|||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-purple-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📋"
|
||||
title="리스트 위젯"
|
||||
type="widget"
|
||||
subtype="list"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult, ClockConfig } from "./types";
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./types";
|
||||
import { QueryEditor } from "./QueryEditor";
|
||||
import { ChartConfigPanel } from "./ChartConfigPanel";
|
||||
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
|
||||
import { ClockConfigModal } from "./widgets/ClockConfigModal";
|
||||
import { DataSourceSelector } from "./data-sources/DataSourceSelector";
|
||||
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { X, ChevronLeft, ChevronRight, Save } from "lucide-react";
|
||||
|
||||
interface ElementConfigModalProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -15,28 +20,52 @@ interface ElementConfigModalProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* 요소 설정 모달 컴포넌트
|
||||
* - 차트/위젯 데이터 소스 설정
|
||||
* - 쿼리 에디터 통합
|
||||
* - 차트 설정 패널 통합
|
||||
* 요소 설정 모달 컴포넌트 (리팩토링)
|
||||
* - 2단계 플로우: 데이터 소스 선택 → 데이터 설정 및 차트 설정
|
||||
* - 새로운 데이터 소스 컴포넌트 통합
|
||||
*/
|
||||
export function ElementConfigModal({ element, isOpen, onClose, onSave }: ElementConfigModalProps) {
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||
element.dataSource || { type: "database", refreshInterval: 30000 },
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
||||
);
|
||||
const [chartConfig, setChartConfig] = useState<ChartConfig>(element.chartConfig || {});
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"query" | "chart">("query");
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2>(1);
|
||||
|
||||
// 차트 설정이 필요 없는 위젯 (쿼리만 필요)
|
||||
const isQueryOnlyWidget =
|
||||
element.subtype === "vehicle-status" ||
|
||||
element.subtype === "vehicle-list" ||
|
||||
element.subtype === "delivery-status";
|
||||
// 모달이 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
||||
setChartConfig(element.chartConfig || {});
|
||||
setQueryResult(null);
|
||||
setCurrentStep(1);
|
||||
}
|
||||
}, [isOpen, element]);
|
||||
|
||||
// 데이터 소스 변경 처리
|
||||
const handleDataSourceChange = useCallback((newDataSource: ChartDataSource) => {
|
||||
setDataSource(newDataSource);
|
||||
// 데이터 소스 타입 변경
|
||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
||||
if (type === "database") {
|
||||
setDataSource({
|
||||
type: "database",
|
||||
connectionType: "current",
|
||||
refreshInterval: 0,
|
||||
});
|
||||
} else {
|
||||
setDataSource({
|
||||
type: "api",
|
||||
method: "GET",
|
||||
refreshInterval: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// 데이터 소스 변경 시 쿼리 결과와 차트 설정 초기화
|
||||
setQueryResult(null);
|
||||
setChartConfig({});
|
||||
}, []);
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 차트 설정 변경 처리
|
||||
|
|
@ -47,11 +76,21 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
// 쿼리 테스트 결과 처리
|
||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||
setQueryResult(result);
|
||||
// 쿼리만 필요한 위젯은 자동 이동 안 함
|
||||
if (result.rows.length > 0 && !isQueryOnlyWidget) {
|
||||
setActiveTab("chart");
|
||||
}, []);
|
||||
|
||||
// 다음 단계로 이동
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep === 1) {
|
||||
setCurrentStep(2);
|
||||
}
|
||||
}, [isQueryOnlyWidget]);
|
||||
}, [currentStep]);
|
||||
|
||||
// 이전 단계로 이동
|
||||
const handlePrev = useCallback(() => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep((prev) => (prev - 1) as 1 | 2);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = useCallback(() => {
|
||||
|
|
@ -64,146 +103,142 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
onClose();
|
||||
}, [element, dataSource, chartConfig, onSave, onClose]);
|
||||
|
||||
// 시계 위젯 설정 저장
|
||||
const handleClockConfigSave = useCallback(
|
||||
(clockConfig: ClockConfig) => {
|
||||
const updatedElement: DashboardElement = {
|
||||
...element,
|
||||
clockConfig,
|
||||
};
|
||||
onSave(updatedElement);
|
||||
},
|
||||
[element, onSave],
|
||||
);
|
||||
|
||||
// 모달이 열려있지 않으면 렌더링하지 않음
|
||||
if (!isOpen) return null;
|
||||
|
||||
// 시계 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
|
||||
if (element.type === "widget" && element.subtype === "clock") {
|
||||
// 시계, 달력, 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
|
||||
if (
|
||||
element.type === "widget" &&
|
||||
(element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 이전 코드 호환성 유지 (아래 주석 처리된 코드는 제거 예정)
|
||||
if (false && element.type === "widget" && element.subtype === "clock") {
|
||||
return (
|
||||
<ClockConfigModal
|
||||
config={
|
||||
element.clockConfig || {
|
||||
style: "digital",
|
||||
timezone: "Asia/Seoul",
|
||||
showDate: true,
|
||||
showSeconds: true,
|
||||
format24h: true,
|
||||
theme: "light",
|
||||
}
|
||||
}
|
||||
onSave={handleClockConfigSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// 저장 가능 여부 확인
|
||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||
const isApiSource = dataSource.type === "api";
|
||||
|
||||
const canSave =
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.xAxis &&
|
||||
(isPieChart || isApiSource
|
||||
? // 파이/도넛 차트 또는 REST API: Y축 또는 집계 함수 필요
|
||||
chartConfig.yAxis ||
|
||||
(Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0) ||
|
||||
chartConfig.aggregation === "count"
|
||||
: // 일반 차트 (DB): Y축 필수
|
||||
chartConfig.yAxis || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
|
||||
|
||||
return (
|
||||
<div className="bg-opacity-50 fixed inset-0 z-[9999] flex items-center justify-center bg-black">
|
||||
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-lg bg-white shadow-xl overflow-hidden">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div
|
||||
className={`flex flex-col rounded-xl border bg-white shadow-2xl ${
|
||||
currentStep === 1 ? "h-auto max-h-[70vh] w-full max-w-3xl" : "h-[85vh] w-full max-w-5xl"
|
||||
}`}
|
||||
>
|
||||
{/* 모달 헤더 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-6 flex-shrink-0">
|
||||
<div className="flex items-center justify-between border-b p-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800">{element.title} 설정</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">데이터 소스와 차트 설정을 구성하세요</p>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{element.title} 설정</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{currentStep === 1 ? "데이터 소스를 선택하세요" : "쿼리를 실행하고 차트를 설정하세요"}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="hover:text-muted-foreground text-2xl text-gray-400">
|
||||
×
|
||||
</button>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<div className="flex border-b border-gray-200 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveTab("query")}
|
||||
className={`border-b-2 px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === "query"
|
||||
? "border-primary text-primary bg-accent"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
} `}
|
||||
>
|
||||
📝 쿼리 & 데이터
|
||||
</button>
|
||||
{!isQueryOnlyWidget && (
|
||||
<button
|
||||
onClick={() => setActiveTab("chart")}
|
||||
className={`border-b-2 px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === "chart"
|
||||
? "border-primary text-primary bg-accent"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
} `}
|
||||
>
|
||||
{element.subtype === "vehicle-map" ? "🗺️ 지도 설정" : "📊 차트 설정"}
|
||||
{queryResult && (
|
||||
<span className="ml-2 rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800">
|
||||
{queryResult.rows.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 탭 내용 - 스크롤 가능하도록 수정 */}
|
||||
<div className="flex-1 overflow-y-auto p-6 relative">
|
||||
{activeTab === "query" && (
|
||||
<div className="relative z-10">
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceChange}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
{/* 진행 상황 표시 */}
|
||||
<div className="border-b bg-gray-50 px-6 py-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-gray-700">
|
||||
단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"}
|
||||
</div>
|
||||
<Badge variant="secondary">{Math.round((currentStep / 2) * 100)}% 완료</Badge>
|
||||
</div>
|
||||
<Progress value={(currentStep / 2) * 100} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* 단계별 내용 */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{currentStep === 1 && (
|
||||
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
|
||||
)}
|
||||
|
||||
{activeTab === "chart" && (
|
||||
<div className="relative z-10">
|
||||
{element.subtype === "vehicle-map" ? (
|
||||
<VehicleMapConfigPanel config={chartConfig} queryResult={queryResult} onConfigChange={handleChartConfigChange} />
|
||||
) : (
|
||||
<ChartConfigPanel config={chartConfig} queryResult={queryResult} onConfigChange={handleChartConfigChange} />
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* 왼쪽: 데이터 설정 */}
|
||||
<div className="space-y-6">
|
||||
{dataSource.type === "database" ? (
|
||||
<>
|
||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceUpdate}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 차트 설정 */}
|
||||
<div>
|
||||
{queryResult && queryResult.rows.length > 0 ? (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||
<div>
|
||||
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 차트 설정이 표시됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="flex items-center justify-between border-t border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">
|
||||
{dataSource.query && (
|
||||
<>
|
||||
💾 쿼리: {dataSource.query.length > 50 ? `${dataSource.query.substring(0, 50)}...` : dataSource.query}
|
||||
</>
|
||||
<div className="flex items-center justify-between border-t bg-gray-50 p-6">
|
||||
<div>
|
||||
{queryResult && (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
📊 {queryResult.rows.length}개 데이터 로드됨
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground rounded-lg border border-gray-300 px-4 py-2 hover:bg-gray-50"
|
||||
>
|
||||
{currentStep > 1 && (
|
||||
<Button variant="outline" onClick={handlePrev}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
!dataSource.query ||
|
||||
(!isQueryOnlyWidget &&
|
||||
(element.subtype === "vehicle-map"
|
||||
? (!chartConfig.latitudeColumn || !chartConfig.longitudeColumn)
|
||||
: (!chartConfig.xAxis || !chartConfig.yAxis)
|
||||
)
|
||||
)
|
||||
}
|
||||
className="bg-accent0 rounded-lg px-4 py-2 text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</Button>
|
||||
{currentStep === 1 ? (
|
||||
<Button onClick={handleNext}>
|
||||
다음
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSave} disabled={!canSave}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { ChartDataSource, QueryResult } from './types';
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { ChartDataSource, QueryResult } from "./types";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Play, Loader2, Database, Code } from "lucide-react";
|
||||
|
||||
interface QueryEditorProps {
|
||||
dataSource?: ChartDataSource;
|
||||
|
|
@ -13,79 +24,88 @@ interface QueryEditorProps {
|
|||
* SQL 쿼리 에디터 컴포넌트
|
||||
* - SQL 쿼리 작성 및 편집
|
||||
* - 쿼리 실행 및 결과 미리보기
|
||||
* - 데이터 소스 설정
|
||||
* - 현재 DB / 외부 DB 분기 처리
|
||||
*/
|
||||
export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: QueryEditorProps) {
|
||||
const [query, setQuery] = useState(dataSource?.query || '');
|
||||
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 () => {
|
||||
console.log('🚀 executeQuery 호출됨!');
|
||||
console.log('📝 현재 쿼리:', query);
|
||||
console.log('✅ query.trim():', query.trim());
|
||||
|
||||
console.log("🚀 executeQuery 호출됨!");
|
||||
console.log("📝 현재 쿼리:", query);
|
||||
console.log("✅ query.trim():", query.trim());
|
||||
|
||||
if (!query.trim()) {
|
||||
setError('쿼리를 입력해주세요.');
|
||||
console.log('❌ 쿼리가 비어있음!');
|
||||
setError("쿼리를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 외부 DB인 경우 커넥션 ID 확인
|
||||
if (dataSource?.connectionType === "external" && !dataSource?.externalConnectionId) {
|
||||
setError("외부 DB 커넥션을 선택해주세요.");
|
||||
console.log("❌ 쿼리가 비어있음!");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExecuting(true);
|
||||
setError(null);
|
||||
console.log('🔄 쿼리 실행 시작...');
|
||||
console.log("🔄 쿼리 실행 시작...");
|
||||
|
||||
try {
|
||||
// 실제 API 호출
|
||||
const response = await fetch('/api/dashboards/execute-query', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('authToken') || localStorage.getItem('token') || 'test-token'}` // JWT 토큰 사용
|
||||
},
|
||||
body: JSON.stringify({ query: query.trim() })
|
||||
});
|
||||
let apiResult: { columns: string[]; rows: any[]; rowCount: number };
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || '쿼리 실행에 실패했습니다.');
|
||||
// 현재 DB vs 외부 DB 분기
|
||||
if (dataSource?.connectionType === "external" && dataSource?.externalConnectionId) {
|
||||
// 외부 DB 쿼리 실행
|
||||
const result = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(dataSource.externalConnectionId),
|
||||
query.trim(),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "외부 DB 쿼리 실행에 실패했습니다.");
|
||||
}
|
||||
|
||||
// ExternalDbConnectionAPI의 응답을 통일된 형식으로 변환
|
||||
apiResult = {
|
||||
columns: result.data?.[0] ? Object.keys(result.data[0]) : [],
|
||||
rows: result.data || [],
|
||||
rowCount: result.data?.length || 0,
|
||||
};
|
||||
} else {
|
||||
// 현재 DB 쿼리 실행
|
||||
apiResult = await dashboardApi.executeQuery(query.trim());
|
||||
}
|
||||
|
||||
const apiResult = await response.json();
|
||||
|
||||
if (!apiResult.success) {
|
||||
throw new Error(apiResult.message || '쿼리 실행에 실패했습니다.');
|
||||
}
|
||||
|
||||
// API 결과를 QueryResult 형식으로 변환
|
||||
// 결과를 QueryResult 형식으로 변환
|
||||
const result: QueryResult = {
|
||||
columns: apiResult.data.columns,
|
||||
rows: apiResult.data.rows,
|
||||
totalRows: apiResult.data.rowCount,
|
||||
executionTime: 0 // API에서 실행 시간을 제공하지 않으므로 0으로 설정
|
||||
columns: apiResult.columns,
|
||||
rows: apiResult.rows,
|
||||
totalRows: apiResult.rowCount,
|
||||
executionTime: 0,
|
||||
};
|
||||
|
||||
|
||||
setQueryResult(result);
|
||||
onQueryTest?.(result);
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
onDataSourceChange({
|
||||
type: 'database',
|
||||
...dataSource,
|
||||
type: "database",
|
||||
query: query.trim(),
|
||||
refreshInterval: dataSource?.refreshInterval || 30000,
|
||||
lastExecuted: new Date().toISOString()
|
||||
refreshInterval: dataSource?.refreshInterval ?? 0,
|
||||
lastExecuted: new Date().toISOString(),
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '쿼리 실행 중 오류가 발생했습니다.';
|
||||
const errorMessage = err instanceof Error ? err.message : "쿼리 실행 중 오류가 발생했습니다.";
|
||||
setError(errorMessage);
|
||||
// console.error('Query execution error:', err);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
}, [query, dataSource?.refreshInterval, onDataSourceChange, onQueryTest]);
|
||||
}, [query, dataSource, onDataSourceChange, onQueryTest]);
|
||||
|
||||
// 샘플 쿼리 삽입
|
||||
const insertSampleQuery = useCallback((sampleType: string) => {
|
||||
|
|
@ -111,7 +131,7 @@ 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,
|
||||
|
|
@ -120,7 +140,7 @@ FROM users
|
|||
WHERE created_at >= CURRENT_DATE - INTERVAL '3 months'
|
||||
GROUP BY DATE_TRUNC('week', created_at)
|
||||
ORDER BY week;`,
|
||||
|
||||
|
||||
products: `-- 상품별 판매량
|
||||
SELECT
|
||||
product_name,
|
||||
|
|
@ -143,198 +163,166 @@ SELECT
|
|||
FROM regional_sales
|
||||
WHERE year = EXTRACT(YEAR FROM CURRENT_DATE)
|
||||
GROUP BY region
|
||||
ORDER BY Q4 DESC;`
|
||||
ORDER BY Q4 DESC;`,
|
||||
};
|
||||
|
||||
setQuery(samples[sampleType as keyof typeof samples] || '');
|
||||
setQuery(samples[sampleType as keyof typeof samples] || "");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
{/* 쿼리 에디터 헤더 */}
|
||||
<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={() => {
|
||||
console.log('🖱️ 실행 버튼 클릭됨!');
|
||||
console.log('📝 query:', query);
|
||||
console.log('🔒 disabled:', isExecuting || !query.trim());
|
||||
executeQuery();
|
||||
}}
|
||||
disabled={isExecuting || !query.trim()}
|
||||
className="
|
||||
px-3 py-1 bg-accent0 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 className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
<h4 className="text-lg font-semibold text-gray-800">SQL 쿼리 에디터</h4>
|
||||
</div>
|
||||
<Button onClick={executeQuery} disabled={isExecuting || !query.trim()} size="sm">
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
실행 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
실행
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 샘플 쿼리 버튼들 */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground">샘플 쿼리:</span>
|
||||
<button
|
||||
onClick={() => insertSampleQuery('comparison')}
|
||||
className="px-2 py-1 text-xs bg-primary/20 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>
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label className="text-sm text-gray-600">샘플 쿼리:</Label>
|
||||
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("comparison")}>
|
||||
<Code className="mr-2 h-3 w-3" />
|
||||
제품 비교
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("regional")}>
|
||||
<Code className="mr-2 h-3 w-3" />
|
||||
지역별 비교
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("sales")}>
|
||||
매출 데이터
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("users")}>
|
||||
사용자 추이
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("products")}>
|
||||
상품 판매량
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 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 className="space-y-2">
|
||||
<Label>SQL 쿼리</Label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="SELECT * FROM your_table WHERE condition = 'value';"
|
||||
className="h-40 resize-none font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 새로고침 간격 설정 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-muted-foreground">자동 새로고침:</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"
|
||||
<Label className="text-sm">자동 새로고침:</Label>
|
||||
<Select
|
||||
value={String(dataSource?.refreshInterval ?? 0)}
|
||||
onValueChange={(value) =>
|
||||
onDataSourceChange({
|
||||
...dataSource,
|
||||
type: "database",
|
||||
query,
|
||||
refreshInterval: parseInt(value),
|
||||
})
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">수동</SelectItem>
|
||||
<SelectItem value="10000">10초</SelectItem>
|
||||
<SelectItem value="30000">30초</SelectItem>
|
||||
<SelectItem value="60000">1분</SelectItem>
|
||||
<SelectItem value="300000">5분</SelectItem>
|
||||
<SelectItem value="600000">10분</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<div className="text-red-800 text-sm font-medium">❌ 오류</div>
|
||||
<div className="text-red-700 text-sm mt-1">{error}</div>
|
||||
</div>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<div className="text-sm font-medium">오류</div>
|
||||
<div className="mt-1 text-sm">{error}</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 쿼리 결과 미리보기 */}
|
||||
{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>
|
||||
<Card>
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">쿼리 결과</span>
|
||||
<Badge variant="secondary">{queryResult.rows.length}행</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">실행 시간: {queryResult.executionTime}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 max-h-60 overflow-auto">
|
||||
|
||||
<div className="p-3">
|
||||
{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-muted-foreground">
|
||||
{String(row[col] ?? '')}
|
||||
</td>
|
||||
<div className="max-h-60 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{queryResult.columns.map((col, idx) => (
|
||||
<TableHead key={idx}>{col}</TableHead>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{queryResult.rows.slice(0, 10).map((row, idx) => (
|
||||
<TableRow key={idx}>
|
||||
{queryResult.columns.map((col, colIdx) => (
|
||||
<TableCell key={colIdx}>{String(row[col] ?? "")}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{queryResult.rows.length > 10 && (
|
||||
<div className="mt-3 text-center text-xs text-gray-500">
|
||||
... 및 {queryResult.rows.length - 10}개 더 (미리보기는 10행까지만 표시)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<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 className="py-8 text-center text-gray-500">결과가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 키보드 단축키 안내 */}
|
||||
<div className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
|
||||
💡 <strong>단축키:</strong> Ctrl+Enter (쿼리 실행), Ctrl+/ (주석 토글)
|
||||
</div>
|
||||
<Card className="p-3">
|
||||
<div className="text-xs text-gray-600">
|
||||
<strong>단축키:</strong> Ctrl+Enter (쿼리 실행), Ctrl+/ (주석 토글)
|
||||
</div>
|
||||
</Card>
|
||||
</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]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -343,18 +331,22 @@ ORDER BY Q4 DESC;`
|
|||
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');
|
||||
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,
|
||||
|
|
@ -374,25 +366,25 @@ function generateSampleQueryResult(query: string): QueryResult {
|
|||
if (isComparison) {
|
||||
// console.log('✅ Using COMPARISON data');
|
||||
// 제품 비교 데이터 (다중 시리즈)
|
||||
columns = ['month', 'galaxy_sales', 'iphone_sales', 'other_sales'];
|
||||
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 },
|
||||
{ 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,
|
||||
// console.log('COMPARISON data generated:', {
|
||||
// columns,
|
||||
// rowCount: rows.length,
|
||||
// sampleRow: rows[0],
|
||||
// allRows: rows,
|
||||
// fieldTypes: {
|
||||
|
|
@ -413,81 +405,81 @@ function generateSampleQueryResult(query: string): QueryResult {
|
|||
} else if (isRegional) {
|
||||
// console.log('✅ Using REGIONAL data');
|
||||
// 지역별 분기별 매출
|
||||
columns = ['지역', 'Q1', 'Q2', 'Q3', 'Q4'];
|
||||
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 },
|
||||
{ 지역: "서울", 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'];
|
||||
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 },
|
||||
{ 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'];
|
||||
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 },
|
||||
{ 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'];
|
||||
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 },
|
||||
{ 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'];
|
||||
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 },
|
||||
{ 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 },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface AreaChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 영역 차트 컴포넌트
|
||||
*/
|
||||
export function AreaChart({ data, config, width = 600, height = 400 }: AreaChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 60 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// X축 스케일
|
||||
const xScale = d3.scalePoint().domain(data.labels).range([0, chartWidth]).padding(0.5);
|
||||
|
||||
// Y축 스케일
|
||||
const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0;
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([chartHeight, 0])
|
||||
.nice();
|
||||
|
||||
// X축 그리기
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.selectAll("text")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end")
|
||||
.style("font-size", "12px");
|
||||
|
||||
// Y축 그리기
|
||||
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickSize(-chartWidth)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 영역 생성기
|
||||
const areaGenerator = d3
|
||||
.area<number>()
|
||||
.x((_, i) => xScale(data.labels[i]) || 0)
|
||||
.y0(chartHeight)
|
||||
.y1((d) => yScale(d));
|
||||
|
||||
// 선 생성기
|
||||
const lineGenerator = d3
|
||||
.line<number>()
|
||||
.x((_, i) => xScale(data.labels[i]) || 0)
|
||||
.y((d) => yScale(d));
|
||||
|
||||
// 부드러운 곡선 적용
|
||||
if (config.lineStyle === "smooth") {
|
||||
areaGenerator.curve(d3.curveMonotoneX);
|
||||
lineGenerator.curve(d3.curveMonotoneX);
|
||||
}
|
||||
|
||||
// 각 데이터셋에 대해 영역 그리기
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const color = dataset.color || colors[i % colors.length];
|
||||
const opacity = config.areaOpacity !== undefined ? config.areaOpacity : 0.3;
|
||||
|
||||
// 영역 그리기
|
||||
const area = g.append("path").datum(dataset.data).attr("fill", color).attr("opacity", 0).attr("d", areaGenerator);
|
||||
|
||||
// 경계선 그리기
|
||||
const line = g
|
||||
.append("path")
|
||||
.datum(dataset.data)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", color)
|
||||
.attr("stroke-width", 2.5)
|
||||
.attr("d", lineGenerator);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
area
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("opacity", opacity);
|
||||
|
||||
const totalLength = line.node()?.getTotalLength() || 0;
|
||||
line
|
||||
.attr("stroke-dasharray", `${totalLength} ${totalLength}`)
|
||||
.attr("stroke-dashoffset", totalLength)
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("stroke-dashoffset", 0);
|
||||
} else {
|
||||
area.attr("opacity", opacity);
|
||||
}
|
||||
|
||||
// 데이터 포인트 (점) 그리기
|
||||
const circles = g
|
||||
.selectAll(`.circle-${i}`)
|
||||
.data(dataset.data)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("class", `circle-${i}`)
|
||||
.attr("cx", (_, j) => xScale(data.labels[j]) || 0)
|
||||
.attr("cy", (d) => yScale(d))
|
||||
.attr("r", 0)
|
||||
.attr("fill", color)
|
||||
.attr("stroke", "white")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
circles
|
||||
.transition()
|
||||
.delay((_, j) => j * 50)
|
||||
.duration(300)
|
||||
.attr("r", 4);
|
||||
} else {
|
||||
circles.attr("r", 4);
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
circles
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("r", 6);
|
||||
|
||||
const [x, y] = d3.pointer(event, g.node());
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${x},${y - 10})`);
|
||||
|
||||
tooltip
|
||||
.append("rect")
|
||||
.attr("x", -40)
|
||||
.attr("y", -30)
|
||||
.attr("width", 80)
|
||||
.attr("height", 25)
|
||||
.attr("fill", "rgba(0,0,0,0.8)")
|
||||
.attr("rx", 4);
|
||||
|
||||
tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.text(`${dataset.label}: ${d}`);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("r", 4);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false && data.datasets.length > 1) {
|
||||
const legend = svg
|
||||
.append("g")
|
||||
.attr("class", "legend")
|
||||
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("opacity", config.areaOpacity !== undefined ? config.areaOpacity : 0.3)
|
||||
.attr("rx", 3);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#333")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} style={{ fontFamily: "sans-serif" }} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface BarChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 막대 차트 컴포넌트
|
||||
*/
|
||||
export function BarChart({ data, config, width = 600, height = 400 }: BarChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 60 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// X축 스케일 (카테고리)
|
||||
const xScale = d3.scaleBand().domain(data.labels).range([0, chartWidth]).padding(0.2);
|
||||
|
||||
// Y축 스케일 (값)
|
||||
const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0;
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([chartHeight, 0])
|
||||
.nice();
|
||||
|
||||
// X축 그리기
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.selectAll("text")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end")
|
||||
.style("font-size", "12px");
|
||||
|
||||
// Y축 그리기
|
||||
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickSize(-chartWidth)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 막대 그리기
|
||||
const barWidth = xScale.bandwidth() / data.datasets.length;
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const bars = g
|
||||
.selectAll(`.bar-${i}`)
|
||||
.data(dataset.data)
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("class", `bar-${i}`)
|
||||
.attr("x", (_, j) => (xScale(data.labels[j]) || 0) + barWidth * i)
|
||||
.attr("y", chartHeight)
|
||||
.attr("width", barWidth)
|
||||
.attr("height", 0)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("rx", 4);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
bars
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("y", (d) => yScale(d))
|
||||
.attr("height", (d) => chartHeight - yScale(d));
|
||||
} else {
|
||||
bars.attr("y", (d) => yScale(d)).attr("height", (d) => chartHeight - yScale(d));
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
bars
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("opacity", 0.7);
|
||||
|
||||
const [mouseX, mouseY] = d3.pointer(event, g.node());
|
||||
const tooltipText = `${dataset.label}: ${d}`;
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${mouseX},${mouseY - 10})`);
|
||||
|
||||
const text = tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("dy", "-0.5em")
|
||||
.text(tooltipText);
|
||||
|
||||
const bbox = (text.node() as SVGTextElement).getBBox();
|
||||
const padding = 8;
|
||||
|
||||
tooltip
|
||||
.insert("rect", "text")
|
||||
.attr("x", bbox.x - padding)
|
||||
.attr("y", bbox.y - padding)
|
||||
.attr("width", bbox.width + padding * 2)
|
||||
.attr("height", bbox.height + padding * 2)
|
||||
.attr("fill", "rgba(0,0,0,0.85)")
|
||||
.attr("rx", 6);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("opacity", 1);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false && data.datasets.length > 1) {
|
||||
const legend = svg
|
||||
.append("g")
|
||||
.attr("class", "legend")
|
||||
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("rx", 3);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#333")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} style={{ fontFamily: "sans-serif" }} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { BarChart } from "./BarChart";
|
||||
import { HorizontalBarChart } from "./HorizontalBarChart";
|
||||
import { LineChart } from "./LineChart";
|
||||
import { AreaChart } from "./AreaChart";
|
||||
import { PieChart } from "./PieChart";
|
||||
import { StackedBarChart } from "./StackedBarChart";
|
||||
import { ComboChart } from "./ComboChart";
|
||||
import { ChartConfig, ChartData, ElementSubtype } from "../types";
|
||||
|
||||
interface ChartProps {
|
||||
chartType: ElementSubtype;
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 차트 컴포넌트
|
||||
* - 차트 타입에 따라 적절한 D3 차트 컴포넌트를 렌더링
|
||||
*/
|
||||
export function Chart({ chartType, data, config, width, height }: ChartProps) {
|
||||
// 데이터가 없으면 placeholder 표시
|
||||
if (!data || !data.labels.length || !data.datasets.length) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50"
|
||||
style={{ width, height }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📊</div>
|
||||
<div className="text-sm font-medium text-gray-600">데이터를 설정하세요</div>
|
||||
<div className="mt-1 text-xs text-gray-500">차트 설정에서 데이터 소스와 축을 설정하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 차트 타입에 따라 렌더링
|
||||
switch (chartType) {
|
||||
case "bar":
|
||||
return <BarChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
case "horizontal-bar":
|
||||
return <HorizontalBarChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
case "line":
|
||||
return <LineChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
case "area":
|
||||
return <AreaChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
case "pie":
|
||||
return <PieChart data={data} config={config} width={width} height={height} isDonut={false} />;
|
||||
|
||||
case "donut":
|
||||
return <PieChart data={data} config={config} width={width} height={height} isDonut={true} />;
|
||||
|
||||
case "stacked-bar":
|
||||
return <StackedBarChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
case "combo":
|
||||
return <ComboChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50"
|
||||
style={{ width, height }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">❓</div>
|
||||
<div className="text-sm font-medium text-gray-600">지원하지 않는 차트 타입</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{chartType}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
'use client';
|
||||
"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';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { DashboardElement, QueryResult, ChartData } from "../types";
|
||||
import { Chart } from "./Chart";
|
||||
import { transformQueryResultToChartData } from "../utils/chartDataTransform";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
|
||||
interface ChartRendererProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -18,85 +15,207 @@ interface ChartRendererProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* 차트 렌더러 컴포넌트 (단순 버전)
|
||||
* - 데이터를 받아서 차트에 그대로 전달
|
||||
* - 복잡한 변환 로직 제거
|
||||
* 차트 렌더러 컴포넌트 (D3 기반)
|
||||
* - 데이터 소스에서 데이터 페칭
|
||||
* - QueryResult를 ChartData로 변환
|
||||
* - D3 Chart 컴포넌트에 전달
|
||||
*/
|
||||
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
|
||||
// });
|
||||
const [chartData, setChartData] = useState<ChartData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 데이터나 설정이 없으면 메시지 표시
|
||||
if (!data || !element.chartConfig?.xAxis || !element.chartConfig?.yAxis) {
|
||||
// 데이터 페칭
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
// 이미 data가 전달된 경우 사용
|
||||
if (data) {
|
||||
const transformed = transformQueryResultToChartData(data, element.chartConfig || {});
|
||||
setChartData(transformed);
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 소스가 설정되어 있으면 페칭
|
||||
if (element.dataSource && element.chartConfig) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let queryResult: QueryResult;
|
||||
|
||||
// REST API vs Database 분기
|
||||
if (element.dataSource.type === "api" && element.dataSource.endpoint) {
|
||||
// REST API - 백엔드 프록시를 통한 호출 (CORS 우회)
|
||||
const params = new URLSearchParams();
|
||||
if (element.dataSource.queryParams) {
|
||||
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: element.dataSource.endpoint,
|
||||
method: "GET",
|
||||
headers: element.dataSource.headers || {},
|
||||
queryParams: Object.fromEntries(params),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "외부 API 호출 실패");
|
||||
}
|
||||
|
||||
const apiData = result.data;
|
||||
|
||||
// JSON Path 처리
|
||||
let processedData = apiData;
|
||||
if (element.dataSource.jsonPath) {
|
||||
const paths = element.dataSource.jsonPath.split(".");
|
||||
for (const path of paths) {
|
||||
if (processedData && typeof processedData === "object" && path in processedData) {
|
||||
processedData = processedData[path];
|
||||
} else {
|
||||
throw new Error(`JSON Path "${element.dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Array.isArray(processedData) ? processedData : [processedData];
|
||||
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
||||
|
||||
queryResult = {
|
||||
columns,
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
executionTime: 0,
|
||||
};
|
||||
} else if (element.dataSource.query) {
|
||||
// Database (현재 DB 또는 외부 DB)
|
||||
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
||||
// 외부 DB
|
||||
const result = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(element.dataSource.externalConnectionId),
|
||||
element.dataSource.query,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "외부 DB 쿼리 실행 실패");
|
||||
}
|
||||
|
||||
queryResult = {
|
||||
columns: result.data?.[0] ? Object.keys(result.data[0]) : [],
|
||||
rows: result.data || [],
|
||||
totalRows: result.data?.length || 0,
|
||||
executionTime: 0,
|
||||
};
|
||||
} else {
|
||||
// 현재 DB
|
||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
queryResult = {
|
||||
columns: result.columns,
|
||||
rows: result.rows,
|
||||
totalRows: result.rowCount,
|
||||
executionTime: 0,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw new Error("데이터 소스가 올바르게 설정되지 않았습니다");
|
||||
}
|
||||
|
||||
// ChartData로 변환
|
||||
const transformed = transformQueryResultToChartData(queryResult, element.chartConfig);
|
||||
setChartData(transformed);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "데이터 로딩 실패";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
// 자동 새로고침 설정 (0이면 수동이므로 interval 설정 안 함)
|
||||
const refreshInterval = element.dataSource?.refreshInterval;
|
||||
if (refreshInterval && refreshInterval > 0) {
|
||||
const interval = setInterval(fetchData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [
|
||||
element.dataSource?.query,
|
||||
element.dataSource?.connectionType,
|
||||
element.dataSource?.externalConnectionId,
|
||||
element.dataSource?.refreshInterval,
|
||||
element.chartConfig,
|
||||
data,
|
||||
]);
|
||||
|
||||
// 로딩 중
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-500 text-sm">
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">📊</div>
|
||||
<div>데이터를 설정해주세요</div>
|
||||
<div className="text-xs mt-1">⚙️ 버튼을 클릭하여 설정</div>
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
<div className="text-sm">데이터 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터가 비어있으면
|
||||
if (!data.rows || data.rows.length === 0) {
|
||||
// 에러
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-red-500 text-sm">
|
||||
<div className="flex h-full w-full items-center justify-center text-red-500">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">⚠️</div>
|
||||
<div>데이터가 없습니다</div>
|
||||
<div className="mb-2 text-2xl">⚠️</div>
|
||||
<div className="text-sm font-medium">오류 발생</div>
|
||||
<div className="mt-1 text-xs">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터를 그대로 전달 (변환 없음!)
|
||||
const chartData = data.rows;
|
||||
// 데이터나 설정이 없으면
|
||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||
const isApiSource = element.dataSource?.type === "api";
|
||||
const needsYAxis = !(isPieChart || isApiSource) || (!element.chartConfig?.aggregation && !element.chartConfig?.yAxis);
|
||||
|
||||
// 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>
|
||||
if (!chartData || !element.chartConfig?.xAxis || (needsYAxis && !element.chartConfig?.yAxis)) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-2xl">📊</div>
|
||||
<div className="text-sm">데이터를 설정해주세요</div>
|
||||
<div className="mt-1 text-xs">⚙️ 버튼을 클릭하여 설정</div>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// D3 차트 렌더링
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-white p-2">
|
||||
<Chart
|
||||
chartType={element.subtype}
|
||||
data={chartData}
|
||||
config={element.chartConfig}
|
||||
width={width - 20}
|
||||
height={height - 20}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,323 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface ComboChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 콤보 차트 컴포넌트 (막대 + 선)
|
||||
* - 첫 번째 데이터셋: 막대 차트
|
||||
* - 나머지 데이터셋: 선 차트
|
||||
*/
|
||||
export function ComboChart({ data, config, width = 600, height = 400 }: ComboChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 60 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// X축 스케일 (카테고리)
|
||||
const xScale = d3.scaleBand().domain(data.labels).range([0, chartWidth]).padding(0.2);
|
||||
|
||||
// Y축 스케일 (값)
|
||||
const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0;
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([chartHeight, 0])
|
||||
.nice();
|
||||
|
||||
// X축 그리기
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.selectAll("text")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end")
|
||||
.style("font-size", "12px");
|
||||
|
||||
// Y축 그리기
|
||||
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickSize(-chartWidth)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 첫 번째 데이터셋: 막대 차트
|
||||
if (data.datasets.length > 0) {
|
||||
const barDataset = data.datasets[0];
|
||||
const bars = g
|
||||
.selectAll(".bar")
|
||||
.data(barDataset.data)
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("class", "bar")
|
||||
.attr("x", (_, j) => xScale(data.labels[j]) || 0)
|
||||
.attr("y", chartHeight)
|
||||
.attr("width", xScale.bandwidth())
|
||||
.attr("height", 0)
|
||||
.attr("fill", barDataset.color || colors[0])
|
||||
.attr("rx", 4);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
bars
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("y", (d) => yScale(d))
|
||||
.attr("height", (d) => chartHeight - yScale(d));
|
||||
} else {
|
||||
bars.attr("y", (d) => yScale(d)).attr("height", (d) => chartHeight - yScale(d));
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
bars
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("opacity", 0.7);
|
||||
|
||||
const [mouseX, mouseY] = d3.pointer(event, g.node());
|
||||
const tooltipText = `${barDataset.label}: ${d}`;
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${mouseX},${mouseY - 10})`);
|
||||
|
||||
const text = tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("dy", "-0.5em")
|
||||
.text(tooltipText);
|
||||
|
||||
const bbox = (text.node() as SVGTextElement).getBBox();
|
||||
const padding = 8;
|
||||
|
||||
tooltip
|
||||
.insert("rect", "text")
|
||||
.attr("x", bbox.x - padding)
|
||||
.attr("y", bbox.y - padding)
|
||||
.attr("width", bbox.width + padding * 2)
|
||||
.attr("height", bbox.height + padding * 2)
|
||||
.attr("fill", "rgba(0,0,0,0.85)")
|
||||
.attr("rx", 6);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("opacity", 1);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 나머지 데이터셋: 선 차트
|
||||
for (let i = 1; i < data.datasets.length; i++) {
|
||||
const dataset = data.datasets[i];
|
||||
const lineColor = dataset.color || colors[i % colors.length];
|
||||
|
||||
// 라인 생성기
|
||||
const line = d3
|
||||
.line<number>()
|
||||
.x((_, j) => (xScale(data.labels[j]) || 0) + xScale.bandwidth() / 2)
|
||||
.y((d) => yScale(d))
|
||||
.curve(d3.curveMonotoneX);
|
||||
|
||||
// 라인 그리기
|
||||
const path = g
|
||||
.append("path")
|
||||
.datum(dataset.data)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", lineColor)
|
||||
.attr("stroke-width", 2)
|
||||
.attr("d", line);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
const totalLength = path.node()?.getTotalLength() || 0;
|
||||
path
|
||||
.attr("stroke-dasharray", `${totalLength} ${totalLength}`)
|
||||
.attr("stroke-dashoffset", totalLength)
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("stroke-dashoffset", 0);
|
||||
}
|
||||
|
||||
// 포인트 그리기
|
||||
const circles = g
|
||||
.selectAll(`.point-${i}`)
|
||||
.data(dataset.data)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("class", `point-${i}`)
|
||||
.attr("cx", (_, j) => (xScale(data.labels[j]) || 0) + xScale.bandwidth() / 2)
|
||||
.attr("cy", (d) => yScale(d))
|
||||
.attr("r", 0)
|
||||
.attr("fill", lineColor)
|
||||
.attr("stroke", "white")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
circles
|
||||
.transition()
|
||||
.delay((_, j) => j * 50)
|
||||
.duration(300)
|
||||
.attr("r", 4);
|
||||
} else {
|
||||
circles.attr("r", 4);
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
circles
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("r", 6);
|
||||
|
||||
const [mouseX, mouseY] = d3.pointer(event, g.node());
|
||||
const tooltipText = `${dataset.label}: ${d}`;
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${mouseX},${mouseY - 10})`);
|
||||
|
||||
const text = tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("dy", "-0.5em")
|
||||
.text(tooltipText);
|
||||
|
||||
const bbox = (text.node() as SVGTextElement).getBBox();
|
||||
const padding = 8;
|
||||
|
||||
tooltip
|
||||
.insert("rect", "text")
|
||||
.attr("x", bbox.x - padding)
|
||||
.attr("y", bbox.y - padding)
|
||||
.attr("width", bbox.width + padding * 2)
|
||||
.attr("height", bbox.height + padding * 2)
|
||||
.attr("fill", "rgba(0,0,0,0.85)")
|
||||
.attr("rx", 6);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("r", 4);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false && data.datasets.length > 0) {
|
||||
const legend = svg.append("g").attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
// 범례 아이콘 (첫 번째는 사각형, 나머지는 라인)
|
||||
if (i === 0) {
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("rx", 3);
|
||||
} else {
|
||||
legendRow
|
||||
.append("line")
|
||||
.attr("x1", 0)
|
||||
.attr("y1", 7)
|
||||
.attr("x2", 15)
|
||||
.attr("y2", 7)
|
||||
.attr("stroke", dataset.color || colors[i % colors.length])
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
legendRow
|
||||
.append("circle")
|
||||
.attr("cx", 7.5)
|
||||
.attr("cy", 7)
|
||||
.attr("r", 3)
|
||||
.attr("fill", dataset.color || colors[i % colors.length]);
|
||||
}
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "11px")
|
||||
.style("fill", "#666")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface HorizontalBarChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 수평 막대 차트 컴포넌트
|
||||
*/
|
||||
export function HorizontalBarChart({ data, config, width = 600, height = 400 }: HorizontalBarChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 120 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// Y축 스케일 (카테고리) - 수평이므로 Y축이 카테고리
|
||||
const yScale = d3.scaleBand().domain(data.labels).range([0, chartHeight]).padding(0.2);
|
||||
|
||||
// X축 스케일 (값) - 수평이므로 X축이 값
|
||||
const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0;
|
||||
const xScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([0, chartWidth])
|
||||
.nice();
|
||||
|
||||
// Y축 그리기 (카테고리)
|
||||
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px").selectAll("text").style("text-anchor", "end");
|
||||
|
||||
// X축 그리기 (값)
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(xScale)
|
||||
.tickSize(chartHeight)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 막대 그리기
|
||||
const barHeight = yScale.bandwidth() / data.datasets.length;
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const bars = g
|
||||
.selectAll(`.bar-${i}`)
|
||||
.data(dataset.data)
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("class", `bar-${i}`)
|
||||
.attr("x", 0)
|
||||
.attr("y", (_, j) => (yScale(data.labels[j]) || 0) + barHeight * i)
|
||||
.attr("width", 0)
|
||||
.attr("height", barHeight)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("ry", 4);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
bars
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("width", (d) => xScale(d));
|
||||
} else {
|
||||
bars.attr("width", (d) => xScale(d));
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
bars
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("opacity", 0.7);
|
||||
|
||||
const [mouseX, mouseY] = d3.pointer(event, g.node());
|
||||
const tooltipText = `${dataset.label}: ${d}`;
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${mouseX},${mouseY - 10})`);
|
||||
|
||||
const text = tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("dy", "-0.5em")
|
||||
.text(tooltipText);
|
||||
|
||||
const bbox = (text.node() as SVGTextElement).getBBox();
|
||||
const padding = 8;
|
||||
|
||||
tooltip
|
||||
.insert("rect", "text")
|
||||
.attr("x", bbox.x - padding)
|
||||
.attr("y", bbox.y - padding)
|
||||
.attr("width", bbox.width + padding * 2)
|
||||
.attr("height", bbox.height + padding * 2)
|
||||
.attr("fill", "rgba(0,0,0,0.85)")
|
||||
.attr("rx", 6);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("opacity", 1);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false && data.datasets.length > 1) {
|
||||
const legend = svg.append("g").attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("rx", 3);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "11px")
|
||||
.style("fill", "#666")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface LineChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 선 차트 컴포넌트
|
||||
*/
|
||||
export function LineChart({ data, config, width = 600, height = 400 }: LineChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 60 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// X축 스케일 (카테고리 → 연속형으로 변환)
|
||||
const xScale = d3.scalePoint().domain(data.labels).range([0, chartWidth]).padding(0.5);
|
||||
|
||||
// Y축 스케일
|
||||
const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0;
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([chartHeight, 0])
|
||||
.nice();
|
||||
|
||||
// X축 그리기
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.selectAll("text")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end")
|
||||
.style("font-size", "12px");
|
||||
|
||||
// Y축 그리기
|
||||
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickSize(-chartWidth)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 선 생성기
|
||||
const lineGenerator = d3
|
||||
.line<number>()
|
||||
.x((_, i) => xScale(data.labels[i]) || 0)
|
||||
.y((d) => yScale(d));
|
||||
|
||||
// 부드러운 곡선 적용
|
||||
if (config.lineStyle === "smooth") {
|
||||
lineGenerator.curve(d3.curveMonotoneX);
|
||||
}
|
||||
|
||||
// 각 데이터셋에 대해 선 그리기
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const color = dataset.color || colors[i % colors.length];
|
||||
|
||||
// 선 그리기
|
||||
const path = g
|
||||
.append("path")
|
||||
.datum(dataset.data)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", color)
|
||||
.attr("stroke-width", 2.5)
|
||||
.attr("d", lineGenerator);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
const totalLength = path.node()?.getTotalLength() || 0;
|
||||
path
|
||||
.attr("stroke-dasharray", `${totalLength} ${totalLength}`)
|
||||
.attr("stroke-dashoffset", totalLength)
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("stroke-dashoffset", 0);
|
||||
}
|
||||
|
||||
// 데이터 포인트 (점) 그리기
|
||||
const circles = g
|
||||
.selectAll(`.circle-${i}`)
|
||||
.data(dataset.data)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("class", `circle-${i}`)
|
||||
.attr("cx", (_, j) => xScale(data.labels[j]) || 0)
|
||||
.attr("cy", (d) => yScale(d))
|
||||
.attr("r", 0)
|
||||
.attr("fill", color)
|
||||
.attr("stroke", "white")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
circles
|
||||
.transition()
|
||||
.delay((_, j) => j * 50)
|
||||
.duration(300)
|
||||
.attr("r", 4);
|
||||
} else {
|
||||
circles.attr("r", 4);
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
circles
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("r", 6);
|
||||
|
||||
const [mouseX, mouseY] = d3.pointer(event, g.node());
|
||||
const tooltipText = `${dataset.label}: ${d}`;
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${mouseX},${mouseY - 10})`);
|
||||
|
||||
const text = tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("dy", "-0.5em")
|
||||
.text(tooltipText);
|
||||
|
||||
const bbox = (text.node() as SVGTextElement).getBBox();
|
||||
const padding = 8;
|
||||
|
||||
tooltip
|
||||
.insert("rect", "text")
|
||||
.attr("x", bbox.x - padding)
|
||||
.attr("y", bbox.y - padding)
|
||||
.attr("width", bbox.width + padding * 2)
|
||||
.attr("height", bbox.height + padding * 2)
|
||||
.attr("fill", "rgba(0,0,0,0.85)")
|
||||
.attr("rx", 6);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("r", 4);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false && data.datasets.length > 1) {
|
||||
const legend = svg
|
||||
.append("g")
|
||||
.attr("class", "legend")
|
||||
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("line")
|
||||
.attr("x1", 0)
|
||||
.attr("y1", 7)
|
||||
.attr("x2", 15)
|
||||
.attr("y2", 7)
|
||||
.attr("stroke", dataset.color || colors[i % colors.length])
|
||||
.attr("stroke-width", 3);
|
||||
|
||||
legendRow
|
||||
.append("circle")
|
||||
.attr("cx", 7.5)
|
||||
.attr("cy", 7)
|
||||
.attr("r", 4)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("stroke", "white")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#333")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} style={{ fontFamily: "sans-serif" }} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface PieChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
isDonut?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 원형 차트 / 도넛 차트 컴포넌트
|
||||
*/
|
||||
export function PieChart({ data, config, width = 500, height = 500, isDonut = false }: PieChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 120, bottom: 40, left: 120 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
const radius = Math.min(chartWidth, chartHeight) / 2;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${width / 2},${height / 2})`);
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899"];
|
||||
|
||||
// 첫 번째 데이터셋 사용
|
||||
const dataset = data.datasets[0];
|
||||
const pieData = data.labels.map((label, i) => ({
|
||||
label,
|
||||
value: dataset.data[i],
|
||||
}));
|
||||
|
||||
// 파이 생성기
|
||||
const pie = d3
|
||||
.pie<{ label: string; value: number }>()
|
||||
.value((d) => d.value)
|
||||
.sort(null);
|
||||
|
||||
// 아크 생성기
|
||||
const innerRadius = isDonut ? radius * (config.pieInnerRadius || 0.5) : 0;
|
||||
const arc = d3.arc<d3.PieArcDatum<{ label: string; value: number }>>().innerRadius(innerRadius).outerRadius(radius);
|
||||
|
||||
// 툴팁용 확대 아크
|
||||
const arcHover = d3
|
||||
.arc<d3.PieArcDatum<{ label: string; value: number }>>()
|
||||
.innerRadius(innerRadius)
|
||||
.outerRadius(radius + 10);
|
||||
|
||||
// 파이 조각 그리기
|
||||
const arcs = g.selectAll(".arc").data(pie(pieData)).enter().append("g").attr("class", "arc");
|
||||
|
||||
const paths = arcs
|
||||
.append("path")
|
||||
.attr("fill", (d, i) => colors[i % colors.length])
|
||||
.attr("stroke", "white")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
paths
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attrTween("d", function (d) {
|
||||
const interpolate = d3.interpolate({ startAngle: 0, endAngle: 0 }, d);
|
||||
return function (t) {
|
||||
return arc(interpolate(t)) || "";
|
||||
};
|
||||
});
|
||||
} else {
|
||||
paths.attr("d", arc);
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
paths
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).transition().duration(200).attr("d", arcHover);
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${arc.centroid(d)[0]},${arc.centroid(d)[1]})`);
|
||||
|
||||
tooltip
|
||||
.append("rect")
|
||||
.attr("x", -50)
|
||||
.attr("y", -40)
|
||||
.attr("width", 100)
|
||||
.attr("height", 35)
|
||||
.attr("fill", "rgba(0,0,0,0.8)")
|
||||
.attr("rx", 4);
|
||||
|
||||
tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("y", -25)
|
||||
.text(d.data.label);
|
||||
|
||||
tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "14px")
|
||||
.attr("font-weight", "bold")
|
||||
.attr("y", -10)
|
||||
.text(`${d.data.value} (${((d.data.value / d3.sum(dataset.data)) * 100).toFixed(1)}%)`);
|
||||
})
|
||||
.on("mouseout", function (event, d) {
|
||||
d3.select(this).transition().duration(200).attr("d", arc);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false) {
|
||||
const legend = svg
|
||||
.append("g")
|
||||
.attr("class", "legend")
|
||||
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
pieData.forEach((d, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", colors[i % colors.length])
|
||||
.attr("rx", 3);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#333")
|
||||
.text(`${d.label} (${d.value})`);
|
||||
});
|
||||
}
|
||||
|
||||
// 도넛 차트 중앙 텍스트
|
||||
if (isDonut) {
|
||||
const total = d3.sum(dataset.data);
|
||||
g.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", -10)
|
||||
.style("font-size", "24px")
|
||||
.style("font-weight", "bold")
|
||||
.style("fill", "#333")
|
||||
.text(total.toLocaleString());
|
||||
|
||||
g.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", 15)
|
||||
.style("font-size", "14px")
|
||||
.style("fill", "#666")
|
||||
.text("Total");
|
||||
}
|
||||
}, [data, config, width, height, isDonut]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} style={{ fontFamily: "sans-serif" }} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface StackedBarChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 누적 막대 차트 컴포넌트
|
||||
*/
|
||||
export function StackedBarChart({ data, config, width = 600, height = 400 }: StackedBarChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 60 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// 데이터 변환 (스택 데이터 생성)
|
||||
const stackData = data.labels.map((label, i) => {
|
||||
const obj: any = { label };
|
||||
data.datasets.forEach((dataset, j) => {
|
||||
obj[`series${j}`] = dataset.data[i];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
|
||||
const series = data.datasets.map((_, i) => `series${i}`);
|
||||
|
||||
// 스택 레이아웃
|
||||
const stack = d3.stack().keys(series);
|
||||
const stackedData = stack(stackData as any);
|
||||
|
||||
// X축 스케일
|
||||
const xScale = d3.scaleBand().domain(data.labels).range([0, chartWidth]).padding(0.3);
|
||||
|
||||
// Y축 스케일
|
||||
const maxValue =
|
||||
config.stackMode === "percent" ? 100 : d3.max(stackedData[stackedData.length - 1], (d) => d[1] as number) || 0;
|
||||
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([chartHeight, 0])
|
||||
.nice();
|
||||
|
||||
// X축 그리기
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.selectAll("text")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end")
|
||||
.style("font-size", "12px");
|
||||
|
||||
// Y축 그리기
|
||||
const yAxis = config.stackMode === "percent" ? d3.axisLeft(yScale).tickFormat((d) => `${d}%`) : d3.axisLeft(yScale);
|
||||
g.append("g").call(yAxis).style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickSize(-chartWidth)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 퍼센트 모드인 경우 데이터 정규화
|
||||
if (config.stackMode === "percent") {
|
||||
stackData.forEach((label) => {
|
||||
const total = d3.sum(series.map((s) => (label as any)[s]));
|
||||
series.forEach((s) => {
|
||||
(label as any)[s] = total > 0 ? ((label as any)[s] / total) * 100 : 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 누적 막대 그리기
|
||||
const layers = g
|
||||
.selectAll(".layer")
|
||||
.data(stackedData)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "layer")
|
||||
.attr("fill", (_, i) => data.datasets[i].color || colors[i % colors.length]);
|
||||
|
||||
const bars = layers
|
||||
.selectAll("rect")
|
||||
.data((d) => d)
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("x", (d) => xScale((d.data as any).label) || 0)
|
||||
.attr("y", chartHeight)
|
||||
.attr("width", xScale.bandwidth())
|
||||
.attr("height", 0)
|
||||
.attr("rx", 4);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
bars
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("y", (d) => yScale(d[1] as number))
|
||||
.attr("height", (d) => yScale(d[0] as number) - yScale(d[1] as number));
|
||||
} else {
|
||||
bars
|
||||
.attr("y", (d) => yScale(d[1] as number))
|
||||
.attr("height", (d) => yScale(d[0] as number) - yScale(d[1] as number));
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
bars
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("opacity", 0.7);
|
||||
|
||||
const seriesIndex = stackedData.findIndex((s) => s.includes(d as any));
|
||||
const value = (d[1] as number) - (d[0] as number);
|
||||
const label = data.datasets[seriesIndex].label;
|
||||
|
||||
const [mouseX, mouseY] = d3.pointer(event, g.node());
|
||||
const tooltipText = `${label}: ${value.toFixed(config.stackMode === "percent" ? 1 : 0)}${config.stackMode === "percent" ? "%" : ""}`;
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${mouseX},${mouseY - 10})`);
|
||||
|
||||
const text = tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("dy", "-0.5em")
|
||||
.text(tooltipText);
|
||||
|
||||
const bbox = (text.node() as SVGTextElement).getBBox();
|
||||
const padding = 8;
|
||||
|
||||
tooltip
|
||||
.insert("rect", "text")
|
||||
.attr("x", bbox.x - padding)
|
||||
.attr("y", bbox.y - padding)
|
||||
.attr("width", bbox.width + padding * 2)
|
||||
.attr("height", bbox.height + padding * 2)
|
||||
.attr("fill", "rgba(0,0,0,0.85)")
|
||||
.attr("rx", 6);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("opacity", 1);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false) {
|
||||
const legend = svg
|
||||
.append("g")
|
||||
.attr("class", "legend")
|
||||
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("rx", 3);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#333")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} style={{ fontFamily: "sans-serif" }} />;
|
||||
}
|
||||
|
|
@ -1,12 +1,8 @@
|
|||
/**
|
||||
* 차트 컴포넌트 인덱스
|
||||
*/
|
||||
|
||||
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';
|
||||
export { Chart } from "./Chart";
|
||||
export { BarChart } from "./BarChart";
|
||||
export { HorizontalBarChart } from "./HorizontalBarChart";
|
||||
export { LineChart } from "./LineChart";
|
||||
export { AreaChart } from "./AreaChart";
|
||||
export { PieChart } from "./PieChart";
|
||||
export { StackedBarChart } from "./StackedBarChart";
|
||||
export { ComboChart } from "./ComboChart";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,370 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ChartDataSource, QueryResult, ApiResponse } from "../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, X, Play, AlertCircle } from "lucide-react";
|
||||
|
||||
interface ApiConfigProps {
|
||||
dataSource: ChartDataSource;
|
||||
onChange: (updates: Partial<ChartDataSource>) => void;
|
||||
onTestResult?: (result: QueryResult) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 설정 컴포넌트
|
||||
* - API 엔드포인트 설정
|
||||
* - 헤더 및 쿼리 파라미터 추가
|
||||
* - JSON Path 설정
|
||||
*/
|
||||
export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<QueryResult | null>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
|
||||
// 헤더 추가
|
||||
const addHeader = () => {
|
||||
const headers = dataSource.headers || {};
|
||||
const newKey = `header_${Object.keys(headers).length + 1}`;
|
||||
onChange({ headers: { ...headers, [newKey]: "" } });
|
||||
};
|
||||
|
||||
// 헤더 제거
|
||||
const removeHeader = (key: string) => {
|
||||
const headers = { ...dataSource.headers };
|
||||
delete headers[key];
|
||||
onChange({ headers });
|
||||
};
|
||||
|
||||
// 헤더 업데이트
|
||||
const updateHeader = (oldKey: string, newKey: string, value: string) => {
|
||||
const headers = { ...dataSource.headers };
|
||||
delete headers[oldKey];
|
||||
headers[newKey] = value;
|
||||
onChange({ headers });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 추가
|
||||
const addQueryParam = () => {
|
||||
const queryParams = dataSource.queryParams || {};
|
||||
const newKey = `param_${Object.keys(queryParams).length + 1}`;
|
||||
onChange({ queryParams: { ...queryParams, [newKey]: "" } });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 제거
|
||||
const removeQueryParam = (key: string) => {
|
||||
const queryParams = { ...dataSource.queryParams };
|
||||
delete queryParams[key];
|
||||
onChange({ queryParams });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 업데이트
|
||||
const updateQueryParam = (oldKey: string, newKey: string, value: string) => {
|
||||
const queryParams = { ...dataSource.queryParams };
|
||||
delete queryParams[oldKey];
|
||||
queryParams[newKey] = value;
|
||||
onChange({ queryParams });
|
||||
};
|
||||
|
||||
// API 테스트
|
||||
const testApi = async () => {
|
||||
if (!dataSource.endpoint) {
|
||||
setTestError("API URL을 입력하세요");
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setTestError(null);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
// 쿼리 파라미터 구성
|
||||
const params = new URLSearchParams();
|
||||
if (dataSource.queryParams) {
|
||||
Object.entries(dataSource.queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 백엔드 프록시를 통한 외부 API 호출 (CORS 우회)
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: dataSource.endpoint,
|
||||
method: "GET",
|
||||
headers: dataSource.headers || {},
|
||||
queryParams: Object.fromEntries(params),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const apiResponse = await response.json();
|
||||
|
||||
if (!apiResponse.success) {
|
||||
throw new Error(apiResponse.message || "외부 API 호출 실패");
|
||||
}
|
||||
|
||||
const apiData = apiResponse.data;
|
||||
|
||||
// JSON Path 처리
|
||||
let data = apiData;
|
||||
if (dataSource.jsonPath) {
|
||||
const paths = dataSource.jsonPath.split(".");
|
||||
for (const path of paths) {
|
||||
if (data && typeof data === "object" && path in data) {
|
||||
data = data[path];
|
||||
} else {
|
||||
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 배열이 아니면 배열로 변환
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new Error("API 응답에 데이터가 없습니다");
|
||||
}
|
||||
|
||||
// 컬럼 추출 및 타입 분석
|
||||
const firstRow = rows[0];
|
||||
const columns = Object.keys(firstRow);
|
||||
|
||||
// 각 컬럼의 타입 분석
|
||||
const columnTypes: Record<string, string> = {};
|
||||
columns.forEach((col) => {
|
||||
const value = firstRow[col];
|
||||
if (value === null || value === undefined) {
|
||||
columnTypes[col] = "null";
|
||||
} else if (Array.isArray(value)) {
|
||||
columnTypes[col] = "array";
|
||||
} else if (typeof value === "object") {
|
||||
columnTypes[col] = "object";
|
||||
} else if (typeof value === "number") {
|
||||
columnTypes[col] = "number";
|
||||
} else if (typeof value === "boolean") {
|
||||
columnTypes[col] = "boolean";
|
||||
} else {
|
||||
columnTypes[col] = "string";
|
||||
}
|
||||
});
|
||||
|
||||
const queryResult: QueryResult = {
|
||||
columns,
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
executionTime: 0,
|
||||
columnTypes, // 타입 정보 추가
|
||||
};
|
||||
|
||||
setTestResult(queryResult);
|
||||
onTestResult?.(queryResult);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다";
|
||||
setTestError(errorMessage);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">2단계: REST API 설정</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">외부 API에서 데이터를 가져올 설정을 입력하세요</p>
|
||||
</div>
|
||||
|
||||
{/* API URL */}
|
||||
<Card className="space-y-4 p-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700">API URL *</Label>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://api.example.com/data"
|
||||
value={dataSource.endpoint || ""}
|
||||
onChange={(e) => onChange({ endpoint: e.target.value })}
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">GET 요청을 보낼 API 엔드포인트</p>
|
||||
</div>
|
||||
|
||||
{/* HTTP 메서드 (고정) */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700">HTTP 메서드</Label>
|
||||
<div className="mt-2 rounded border border-gray-300 bg-gray-100 p-2 text-sm text-gray-700">GET (고정)</div>
|
||||
<p className="mt-1 text-xs text-gray-500">데이터 조회는 GET 메서드만 지원합니다</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 쿼리 파라미터 */}
|
||||
<Card className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-gray-700">URL 쿼리 파라미터</Label>
|
||||
<Button variant="outline" size="sm" onClick={addQueryParam}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dataSource.queryParams && Object.keys(dataSource.queryParams).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(dataSource.queryParams).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="key"
|
||||
value={key}
|
||||
onChange={(e) => updateQueryParam(key, e.target.value, value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="value"
|
||||
value={value}
|
||||
onChange={(e) => updateQueryParam(key, key, e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={() => removeQueryParam(key)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-2 text-center text-sm text-gray-500">추가된 파라미터가 없습니다</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500">예: category=electronics, limit=10</p>
|
||||
</Card>
|
||||
|
||||
{/* 헤더 */}
|
||||
<Card className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-gray-700">요청 헤더</Label>
|
||||
<Button variant="outline" size="sm" onClick={addHeader}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 빠른 헤더 템플릿 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onChange({
|
||||
headers: { ...dataSource.headers, Authorization: "Bearer YOUR_TOKEN" },
|
||||
});
|
||||
}}
|
||||
>
|
||||
+ Authorization
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onChange({
|
||||
headers: { ...dataSource.headers, "Content-Type": "application/json" },
|
||||
});
|
||||
}}
|
||||
>
|
||||
+ Content-Type
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dataSource.headers && Object.keys(dataSource.headers).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(dataSource.headers).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Header Name"
|
||||
value={key}
|
||||
onChange={(e) => updateHeader(key, e.target.value, value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Header Value"
|
||||
value={value}
|
||||
onChange={(e) => updateHeader(key, key, e.target.value)}
|
||||
className="flex-1"
|
||||
type={key.toLowerCase().includes("auth") ? "password" : "text"}
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={() => removeHeader(key)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-2 text-center text-sm text-gray-500">추가된 헤더가 없습니다</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* JSON Path */}
|
||||
<Card className="space-y-2 p-4">
|
||||
<Label className="text-sm font-medium text-gray-700">JSON Path (선택)</Label>
|
||||
<Input
|
||||
placeholder="data.results"
|
||||
value={dataSource.jsonPath || ""}
|
||||
onChange={(e) => onChange({ jsonPath: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
JSON 응답에서 데이터 배열의 경로 (예: data.results, items, response.data)
|
||||
<br />
|
||||
비워두면 전체 응답을 사용합니다
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* 테스트 버튼 */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={testApi} disabled={!dataSource.endpoint || testing}>
|
||||
{testing ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
테스트 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
API 테스트
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테스트 오류 */}
|
||||
{testError && (
|
||||
<Card className="border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-600" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-red-800">API 호출 실패</div>
|
||||
<div className="mt-1 text-sm text-red-700">{testError}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 테스트 결과 */}
|
||||
{testResult && (
|
||||
<Card className="border-green-200 bg-green-50 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-green-800">✅ API 호출 성공</div>
|
||||
<div className="space-y-1 text-xs text-green-700">
|
||||
<div>총 {testResult.rows.length}개의 데이터를 불러왔습니다</div>
|
||||
<div>컬럼: {testResult.columns.join(", ")}</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ChartDataSource } from "../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Database, Globe } from "lucide-react";
|
||||
|
||||
interface DataSourceSelectorProps {
|
||||
dataSource: ChartDataSource;
|
||||
onTypeChange: (type: "database" | "api") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 소스 선택 컴포넌트
|
||||
* - DB vs API 선택
|
||||
* - 큰 카드 UI로 직관적인 선택
|
||||
*/
|
||||
export function DataSourceSelector({ dataSource, onTypeChange }: DataSourceSelectorProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">1단계: 데이터 소스 선택</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">차트에 표시할 데이터를 어디서 가져올지 선택하세요</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 데이터베이스 옵션 */}
|
||||
<Card
|
||||
className={`cursor-pointer p-6 transition-all ${
|
||||
dataSource.type === "database"
|
||||
? "border-2 border-blue-500 bg-blue-50"
|
||||
: "border-2 border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => onTypeChange("database")}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-3 text-center">
|
||||
<div className={`rounded-full p-4 ${dataSource.type === "database" ? "bg-blue-100" : "bg-gray-100"}`}>
|
||||
<Database className={`h-8 w-8 ${dataSource.type === "database" ? "text-blue-600" : "text-gray-600"}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">데이터베이스</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">SQL 쿼리로 데이터 조회</p>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
<div>✓ 현재 DB 또는 외부 DB</div>
|
||||
<div>✓ SELECT 쿼리 지원</div>
|
||||
<div>✓ 실시간 데이터 조회</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* REST API 옵션 */}
|
||||
<Card
|
||||
className={`cursor-pointer p-6 transition-all ${
|
||||
dataSource.type === "api"
|
||||
? "border-2 border-green-500 bg-green-50"
|
||||
: "border-2 border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => onTypeChange("api")}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-3 text-center">
|
||||
<div className={`rounded-full p-4 ${dataSource.type === "api" ? "bg-green-100" : "bg-gray-100"}`}>
|
||||
<Globe className={`h-8 w-8 ${dataSource.type === "api" ? "text-green-600" : "text-gray-600"}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">REST API</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">외부 API에서 데이터 가져오기</p>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
<div>✓ GET 요청 지원</div>
|
||||
<div>✓ JSON 응답 파싱</div>
|
||||
<div>✓ 커스텀 헤더 설정</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 선택된 타입 표시 */}
|
||||
{dataSource.type && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-gray-700">선택됨:</span>
|
||||
<span className="text-gray-900">{dataSource.type === "database" ? "🗄️ 데이터베이스" : "🌐 REST API"}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChartDataSource } from "../types";
|
||||
import { ExternalDbConnectionAPI, ExternalDbConnection } from "@/lib/api/externalDbConnection";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink, Database, Server } from "lucide-react";
|
||||
|
||||
interface DatabaseConfigProps {
|
||||
dataSource: ChartDataSource;
|
||||
onChange: (updates: Partial<ChartDataSource>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스 설정 컴포넌트
|
||||
* - 현재 DB / 외부 DB 선택
|
||||
* - 외부 커넥션 목록 불러오기
|
||||
*/
|
||||
export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||
const [connections, setConnections] = useState<ExternalDbConnection[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 외부 커넥션 목록 불러오기
|
||||
useEffect(() => {
|
||||
if (dataSource.connectionType === "external") {
|
||||
loadExternalConnections();
|
||||
}
|
||||
}, [dataSource.connectionType]);
|
||||
|
||||
const loadExternalConnections = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const activeConnections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
|
||||
setConnections(activeConnections);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 현재 선택된 커넥션 찾기
|
||||
const selectedConnection = connections.find((conn) => String(conn.id) === dataSource.externalConnectionId);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">2단계: 데이터베이스 설정</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">데이터를 조회할 데이터베이스를 선택하세요</p>
|
||||
</div>
|
||||
|
||||
{/* 현재 DB vs 외부 DB 선택 */}
|
||||
<Card className="p-4">
|
||||
<Label className="mb-3 block text-sm font-medium text-gray-700">데이터베이스 선택</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant={dataSource.connectionType === "current" ? "default" : "outline"}
|
||||
className="h-auto justify-start py-3"
|
||||
onClick={() => {
|
||||
onChange({ connectionType: "current", externalConnectionId: undefined });
|
||||
}}
|
||||
>
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">현재 데이터베이스</div>
|
||||
<div className="text-xs opacity-80">애플리케이션 기본 DB</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={dataSource.connectionType === "external" ? "default" : "outline"}
|
||||
className="h-auto justify-start py-3"
|
||||
onClick={() => {
|
||||
onChange({ connectionType: "external" });
|
||||
}}
|
||||
>
|
||||
<Server className="mr-2 h-4 w-4" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">외부 데이터베이스</div>
|
||||
<div className="text-xs opacity-80">등록된 외부 커넥션</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 외부 DB 선택 시 커넥션 목록 */}
|
||||
{dataSource.connectionType === "external" && (
|
||||
<Card className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-gray-700">외부 커넥션 선택</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.open("/admin/external-connections", "_blank");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<ExternalLink className="mr-1 h-3 w-3" />
|
||||
커넥션 관리
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
|
||||
<span className="ml-2 text-sm text-gray-600">커넥션 목록 불러오는 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<div className="text-sm text-red-800">❌ {error}</div>
|
||||
<Button variant="ghost" size="sm" onClick={loadExternalConnections} className="mt-2 text-xs">
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && connections.length === 0 && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-center">
|
||||
<div className="mb-2 text-sm text-yellow-800">등록된 외부 커넥션이 없습니다</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.open("/admin/external-connections", "_blank");
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="mr-1 h-3 w-3" />
|
||||
커넥션 등록하기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && connections.length > 0 && (
|
||||
<>
|
||||
<Select
|
||||
value={dataSource.externalConnectionId || undefined}
|
||||
onValueChange={(value) => {
|
||||
onChange({ externalConnectionId: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={String(conn.id)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500">({conn.db_type.toUpperCase()})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedConnection && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium">커넥션명:</span> {selectedConnection.connection_name}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">타입:</span> {selectedConnection.db_type.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 다음 단계 안내 */}
|
||||
{(dataSource.connectionType === "current" ||
|
||||
(dataSource.connectionType === "external" && dataSource.externalConnectionId)) && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div className="text-sm text-blue-800">✅ 데이터베이스가 선택되었습니다. 아래에서 SQL 쿼리를 작성하세요.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import { QueryResult } from "../types";
|
||||
|
||||
/**
|
||||
* JSON Path를 사용하여 객체에서 데이터 추출
|
||||
* @param obj JSON 객체
|
||||
* @param path 경로 (예: "data.results", "items")
|
||||
* @returns 추출된 데이터
|
||||
*/
|
||||
export function extractDataFromJsonPath(obj: any, path: string): any {
|
||||
if (!path || path.trim() === "") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const keys = path.split(".");
|
||||
let result = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (result === null || result === undefined) {
|
||||
return null;
|
||||
}
|
||||
result = result[key];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답을 QueryResult 형식으로 변환
|
||||
* @param data API 응답 데이터
|
||||
* @param jsonPath JSON Path (선택)
|
||||
* @returns QueryResult
|
||||
*/
|
||||
export function transformApiResponseToQueryResult(data: any, jsonPath?: string): QueryResult {
|
||||
try {
|
||||
// JSON Path가 있으면 데이터 추출
|
||||
let extractedData = jsonPath ? extractDataFromJsonPath(data, jsonPath) : data;
|
||||
|
||||
// 배열이 아니면 배열로 변환
|
||||
if (!Array.isArray(extractedData)) {
|
||||
// 객체인 경우 키-값 쌍을 배열로 변환
|
||||
if (typeof extractedData === "object" && extractedData !== null) {
|
||||
extractedData = Object.entries(extractedData).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
} else {
|
||||
throw new Error("데이터가 배열 또는 객체 형식이 아닙니다");
|
||||
}
|
||||
}
|
||||
|
||||
if (extractedData.length === 0) {
|
||||
return {
|
||||
columns: [],
|
||||
rows: [],
|
||||
totalRows: 0,
|
||||
executionTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 첫 번째 행에서 컬럼 추출
|
||||
const firstRow = extractedData[0];
|
||||
const columns = Object.keys(firstRow);
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows: extractedData,
|
||||
totalRows: extractedData.length,
|
||||
executionTime: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`API 응답 변환 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 소스가 유효한지 검증
|
||||
* @param type 데이터 소스 타입
|
||||
* @param connectionType 커넥션 타입 (DB일 때)
|
||||
* @param externalConnectionId 외부 커넥션 ID (외부 DB일 때)
|
||||
* @param query SQL 쿼리 (DB일 때)
|
||||
* @param endpoint API URL (API일 때)
|
||||
* @returns 유효성 검증 결과
|
||||
*/
|
||||
export function validateDataSource(
|
||||
type: "database" | "api",
|
||||
connectionType?: "current" | "external",
|
||||
externalConnectionId?: string,
|
||||
query?: string,
|
||||
endpoint?: string,
|
||||
): { valid: boolean; message?: string } {
|
||||
if (type === "database") {
|
||||
// DB 검증
|
||||
if (!connectionType) {
|
||||
return { valid: false, message: "데이터베이스 타입을 선택하세요" };
|
||||
}
|
||||
|
||||
if (connectionType === "external" && !externalConnectionId) {
|
||||
return { valid: false, message: "외부 커넥션을 선택하세요" };
|
||||
}
|
||||
|
||||
if (!query || query.trim() === "") {
|
||||
return { valid: false, message: "SQL 쿼리를 입력하세요" };
|
||||
}
|
||||
|
||||
// SELECT 쿼리인지 검증 (간단한 검증)
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
if (!trimmedQuery.startsWith("select")) {
|
||||
return { valid: false, message: "SELECT 쿼리만 허용됩니다" };
|
||||
}
|
||||
|
||||
// 위험한 키워드 체크
|
||||
const dangerousKeywords = ["drop", "delete", "insert", "update", "truncate", "alter", "create", "exec", "execute"];
|
||||
for (const keyword of dangerousKeywords) {
|
||||
if (trimmedQuery.includes(keyword)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `보안상 ${keyword.toUpperCase()} 명령은 사용할 수 없습니다`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
} else if (type === "api") {
|
||||
// API 검증
|
||||
if (!endpoint || endpoint.trim() === "") {
|
||||
return { valid: false, message: "API URL을 입력하세요" };
|
||||
}
|
||||
|
||||
// URL 형식 검증
|
||||
try {
|
||||
new URL(endpoint);
|
||||
} catch {
|
||||
return { valid: false, message: "올바른 URL 형식이 아닙니다" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
return { valid: false, message: "알 수 없는 데이터 소스 타입입니다" };
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 파라미터를 URL에 추가
|
||||
* @param baseUrl 기본 URL
|
||||
* @param params 쿼리 파라미터 객체
|
||||
* @returns 쿼리 파라미터가 추가된 URL
|
||||
*/
|
||||
export function buildUrlWithParams(baseUrl: string, params?: Record<string, string>): string {
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
const url = new URL(baseUrl);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
url.searchParams.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 데이터 타입 추론
|
||||
* @param rows 데이터 행
|
||||
* @param columnName 컬럼명
|
||||
* @returns 데이터 타입 ('string' | 'number' | 'date' | 'boolean')
|
||||
*/
|
||||
export function inferColumnType(rows: Record<string, any>[], columnName: string): string {
|
||||
if (rows.length === 0) {
|
||||
return "string";
|
||||
}
|
||||
|
||||
const sampleValue = rows[0][columnName];
|
||||
|
||||
if (typeof sampleValue === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (typeof sampleValue === "boolean") {
|
||||
return "boolean";
|
||||
}
|
||||
|
||||
if (typeof sampleValue === "string") {
|
||||
// 날짜 형식인지 확인
|
||||
if (!isNaN(Date.parse(sampleValue))) {
|
||||
return "date";
|
||||
}
|
||||
return "string";
|
||||
}
|
||||
|
||||
return "string";
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export type ElementType = "chart" | "widget";
|
|||
|
||||
export type ElementSubtype =
|
||||
| "bar"
|
||||
| "horizontal-bar"
|
||||
| "pie"
|
||||
| "line"
|
||||
| "area"
|
||||
|
|
@ -26,7 +27,8 @@ export type ElementSubtype =
|
|||
| "todo"
|
||||
| "booking-alert"
|
||||
| "maintenance"
|
||||
| "document"; // 위젯 타입
|
||||
| "document"
|
||||
| "list"; // 위젯 타입
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
|
|
@ -51,6 +53,7 @@ export interface DashboardElement {
|
|||
clockConfig?: ClockConfig; // 시계 설정
|
||||
calendarConfig?: CalendarConfig; // 달력 설정
|
||||
driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
|
||||
listConfig?: ListWidgetConfig; // 리스트 위젯 설정
|
||||
}
|
||||
|
||||
export interface DragData {
|
||||
|
|
@ -64,23 +67,64 @@ export interface ResizeHandle {
|
|||
}
|
||||
|
||||
export interface ChartDataSource {
|
||||
type: "api" | "database" | "static";
|
||||
endpoint?: string; // API 엔드포인트
|
||||
query?: string; // SQL 쿼리
|
||||
refreshInterval?: number; // 자동 새로고침 간격 (ms)
|
||||
filters?: any[]; // 필터 조건
|
||||
type: "database" | "api"; // 데이터 소스 타입
|
||||
|
||||
// DB 커넥션 관련
|
||||
connectionType?: "current" | "external"; // 현재 DB vs 외부 DB
|
||||
externalConnectionId?: string; // 외부 DB 커넥션 ID
|
||||
query?: string; // SQL 쿼리 (SELECT만)
|
||||
|
||||
// API 관련
|
||||
endpoint?: string; // API URL
|
||||
method?: "GET"; // HTTP 메서드 (GET만 지원)
|
||||
headers?: Record<string, string>; // 커스텀 헤더
|
||||
queryParams?: Record<string, string>; // URL 쿼리 파라미터
|
||||
jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results")
|
||||
|
||||
// 공통
|
||||
refreshInterval?: number; // 자동 새로고침 (초, 0이면 수동)
|
||||
lastExecuted?: string; // 마지막 실행 시간
|
||||
lastError?: string; // 마지막 오류 메시지
|
||||
}
|
||||
|
||||
export interface ChartConfig {
|
||||
xAxis?: string; // X축 데이터 필드
|
||||
yAxis?: string | string[]; // Y축 데이터 필드 (단일 또는 다중)
|
||||
// 축 매핑
|
||||
xAxis?: string; // X축 필드명
|
||||
yAxis?: string | string[]; // Y축 필드명 (다중 가능)
|
||||
|
||||
// 데이터 처리
|
||||
groupBy?: string; // 그룹핑 필드
|
||||
aggregation?: "sum" | "avg" | "count" | "max" | "min";
|
||||
colors?: string[]; // 차트 색상
|
||||
sortBy?: string; // 정렬 기준 필드
|
||||
sortOrder?: "asc" | "desc"; // 정렬 순서
|
||||
limit?: number; // 데이터 개수 제한
|
||||
|
||||
// 스타일
|
||||
colors?: string[]; // 차트 색상 팔레트
|
||||
title?: string; // 차트 제목
|
||||
showLegend?: boolean; // 범례 표시 여부
|
||||
|
||||
showLegend?: boolean; // 범례 표시
|
||||
legendPosition?: "top" | "bottom" | "left" | "right"; // 범례 위치
|
||||
|
||||
// 축 설정
|
||||
xAxisLabel?: string; // X축 라벨
|
||||
yAxisLabel?: string; // Y축 라벨
|
||||
showGrid?: boolean; // 그리드 표시
|
||||
|
||||
// 애니메이션
|
||||
enableAnimation?: boolean; // 애니메이션 활성화
|
||||
animationDuration?: number; // 애니메이션 시간 (ms)
|
||||
|
||||
// 툴팁
|
||||
showTooltip?: boolean; // 툴팁 표시
|
||||
tooltipFormat?: string; // 툴팁 포맷 (템플릿)
|
||||
|
||||
// 차트별 특수 설정
|
||||
barOrientation?: "vertical" | "horizontal"; // 막대 방향
|
||||
lineStyle?: "smooth" | "straight"; // 선 스타일
|
||||
areaOpacity?: number; // 영역 투명도
|
||||
pieInnerRadius?: number; // 도넛 차트 내부 반지름 (0-1)
|
||||
stackMode?: "normal" | "percent"; // 누적 모드
|
||||
|
||||
// 지도 관련 설정
|
||||
latitudeColumn?: string; // 위도 컬럼
|
||||
longitudeColumn?: string; // 경도 컬럼
|
||||
|
|
@ -94,6 +138,7 @@ export interface QueryResult {
|
|||
totalRows: number; // 전체 행 수
|
||||
executionTime: number; // 실행 시간 (ms)
|
||||
error?: string; // 오류 메시지
|
||||
columnTypes?: Record<string, string>; // 각 컬럼의 타입 정보 (number, string, object, array 등)
|
||||
}
|
||||
|
||||
// 시계 위젯 설정
|
||||
|
|
@ -145,3 +190,53 @@ export interface DriverInfo {
|
|||
estimatedArrival?: string; // 예상 도착 시간
|
||||
progress?: number; // 운행 진행률 (0-100)
|
||||
}
|
||||
|
||||
// 외부 DB 커넥션 정보 (기존 외부 커넥션 관리에서 가져옴)
|
||||
export interface ExternalConnection {
|
||||
id: string;
|
||||
name: string; // 사용자 지정 이름 (표시용)
|
||||
type: "postgresql" | "mysql" | "mssql" | "oracle";
|
||||
}
|
||||
|
||||
// API 응답 구조
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 차트 데이터 (변환 후)
|
||||
export interface ChartData {
|
||||
labels: string[]; // X축 레이블
|
||||
datasets: ChartDataset[]; // Y축 데이터셋 (다중 시리즈)
|
||||
}
|
||||
|
||||
export interface ChartDataset {
|
||||
label: string; // 시리즈 이름
|
||||
data: number[]; // 데이터 값
|
||||
color?: string; // 색상
|
||||
}
|
||||
|
||||
// 리스트 위젯 설정
|
||||
export interface ListWidgetConfig {
|
||||
columnMode: "auto" | "manual"; // 컬럼 설정 방식 (자동 or 수동)
|
||||
viewMode: "table" | "card"; // 뷰 모드 (테이블 or 카드) (기본: table)
|
||||
columns: ListColumn[]; // 컬럼 정의
|
||||
pageSize: number; // 페이지당 행 수 (기본: 10)
|
||||
enablePagination: boolean; // 페이지네이션 활성화 (기본: true)
|
||||
showHeader: boolean; // 헤더 표시 (기본: true, 테이블 모드에만 적용)
|
||||
stripedRows: boolean; // 줄무늬 행 (기본: true, 테이블 모드에만 적용)
|
||||
compactMode: boolean; // 압축 모드 (기본: false)
|
||||
cardColumns: number; // 카드 뷰 컬럼 수 (기본: 3)
|
||||
}
|
||||
|
||||
// 리스트 컬럼
|
||||
export interface ListColumn {
|
||||
id: string; // 고유 ID
|
||||
label: string; // 표시될 컬럼명
|
||||
field: string; // 데이터 필드명
|
||||
width?: number; // 너비 (px)
|
||||
align?: "left" | "center" | "right"; // 정렬
|
||||
visible?: boolean; // 표시 여부 (기본: true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
import { QueryResult, ChartConfig, ChartData, ChartDataset } from "../types";
|
||||
|
||||
/**
|
||||
* 쿼리 결과를 차트 데이터로 변환
|
||||
*/
|
||||
export function transformQueryResultToChartData(queryResult: QueryResult, config: ChartConfig): ChartData | null {
|
||||
if (!queryResult || !queryResult.rows.length || !config.xAxis) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rows = queryResult.rows;
|
||||
|
||||
// 그룹핑 처리
|
||||
if (config.groupBy && config.groupBy !== "__none__") {
|
||||
rows = applyGrouping(rows, config.groupBy, config.aggregation, config.yAxis);
|
||||
}
|
||||
|
||||
// X축 라벨 추출
|
||||
const labels = rows.map((row) => String(row[config.xAxis!] || ""));
|
||||
|
||||
// Y축 데이터 추출
|
||||
const yAxisFields = Array.isArray(config.yAxis) ? config.yAxis : config.yAxis ? [config.yAxis] : [];
|
||||
|
||||
// 집계 함수가 COUNT이고 Y축이 없으면 자동으로 count 필드 추가
|
||||
if (config.aggregation === "count" && yAxisFields.length === 0) {
|
||||
const datasets: ChartDataset[] = [
|
||||
{
|
||||
label: "개수",
|
||||
data: rows.map((row) => {
|
||||
const value = row["count"];
|
||||
return typeof value === "number" ? value : parseFloat(String(value)) || 0;
|
||||
}),
|
||||
color: config.colors?.[0],
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets,
|
||||
};
|
||||
}
|
||||
|
||||
if (yAxisFields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 각 Y축 필드에 대해 데이터셋 생성
|
||||
const datasets: ChartDataset[] = yAxisFields.map((field, index) => {
|
||||
const data = rows.map((row) => {
|
||||
const value = row[field];
|
||||
return typeof value === "number" ? value : parseFloat(String(value)) || 0;
|
||||
});
|
||||
|
||||
return {
|
||||
label: field,
|
||||
data,
|
||||
color: config.colors?.[index],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹핑 및 집계 처리
|
||||
*/
|
||||
function applyGrouping(
|
||||
rows: Record<string, any>[],
|
||||
groupByField: string,
|
||||
aggregation?: "sum" | "avg" | "count" | "max" | "min",
|
||||
yAxis?: string | string[],
|
||||
): Record<string, any>[] {
|
||||
// 그룹별로 데이터 묶기
|
||||
const groups = new Map<string, Record<string, any>[]>();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const key = String(row[groupByField] || "");
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key)!.push(row);
|
||||
});
|
||||
|
||||
// 각 그룹에 대해 집계 수행
|
||||
const aggregatedRows: Record<string, any>[] = [];
|
||||
|
||||
groups.forEach((groupRows, key) => {
|
||||
const aggregatedRow: Record<string, any> = {
|
||||
[groupByField]: key,
|
||||
};
|
||||
|
||||
// Y축 필드에 대해 집계
|
||||
const yAxisFields = Array.isArray(yAxis) ? yAxis : yAxis ? [yAxis] : [];
|
||||
|
||||
if (aggregation === "count") {
|
||||
// COUNT: 그룹의 행 개수
|
||||
aggregatedRow["count"] = groupRows.length;
|
||||
} else if (yAxisFields.length > 0) {
|
||||
yAxisFields.forEach((field) => {
|
||||
const values = groupRows.map((row) => {
|
||||
const value = row[field];
|
||||
return typeof value === "number" ? value : parseFloat(String(value)) || 0;
|
||||
});
|
||||
|
||||
switch (aggregation) {
|
||||
case "sum":
|
||||
aggregatedRow[field] = values.reduce((a, b) => a + b, 0);
|
||||
break;
|
||||
case "avg":
|
||||
aggregatedRow[field] = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
break;
|
||||
case "max":
|
||||
aggregatedRow[field] = Math.max(...values);
|
||||
break;
|
||||
case "min":
|
||||
aggregatedRow[field] = Math.min(...values);
|
||||
break;
|
||||
default:
|
||||
// 집계 없으면 첫 번째 값 사용
|
||||
aggregatedRow[field] = values[0];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
aggregatedRows.push(aggregatedRow);
|
||||
});
|
||||
|
||||
return aggregatedRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답을 차트 데이터로 변환
|
||||
*/
|
||||
export function transformApiResponseToChartData(
|
||||
apiData: Record<string, unknown>[],
|
||||
config: ChartConfig,
|
||||
): ChartData | null {
|
||||
// API 응답을 QueryResult 형식으로 변환
|
||||
if (!apiData || apiData.length === 0 || !config.xAxis) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queryResult: QueryResult = {
|
||||
columns: Object.keys(apiData[0]),
|
||||
rows: apiData,
|
||||
totalRows: apiData.length,
|
||||
executionTime: 0,
|
||||
};
|
||||
|
||||
return transformQueryResultToChartData(queryResult, config);
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
# 리스트 위젯 개발 계획서
|
||||
|
||||
## 📋 개요
|
||||
|
||||
차트와 동일한 방식으로 데이터를 가져오는 리스트(테이블) 위젯 개발
|
||||
|
||||
---
|
||||
|
||||
## 🎯 주요 기능
|
||||
|
||||
### 1. 데이터 소스 (차트와 동일)
|
||||
|
||||
- **내부 DB**: 현재 데이터베이스 쿼리
|
||||
- **외부 DB**: 외부 커넥션 관리에서 설정된 DB 쿼리
|
||||
- **REST API**: 외부 API 호출 (GET 방식)
|
||||
|
||||
### 2. 컬럼 설정
|
||||
|
||||
사용자가 두 가지 방식으로 컬럼을 설정할 수 있음:
|
||||
|
||||
#### 방식 1: 데이터 기반 자동 생성
|
||||
|
||||
1. 쿼리/API 실행 → 데이터 가져옴
|
||||
2. 사용자가 표시할 컬럼 선택
|
||||
3. 컬럼명을 원하는대로 변경 가능
|
||||
4. 컬럼 순서 조정 가능
|
||||
|
||||
```
|
||||
예시:
|
||||
데이터: { userId: 1, userName: "홍길동", deptCode: "DPT001" }
|
||||
↓
|
||||
사용자 설정:
|
||||
- userId → "사용자 ID"
|
||||
- userName → "이름"
|
||||
- deptCode → "부서 코드"
|
||||
```
|
||||
|
||||
#### 방식 2: 수동 컬럼 정의
|
||||
|
||||
1. 사용자가 직접 컬럼 추가
|
||||
2. 각 컬럼의 이름 지정
|
||||
3. 각 컬럼에 들어갈 데이터 필드 매핑
|
||||
|
||||
```
|
||||
예시:
|
||||
컬럼 1: "직원 정보" → userName 필드
|
||||
컬럼 2: "소속" → deptCode 필드
|
||||
컬럼 3: "등록일" → regDate 필드
|
||||
```
|
||||
|
||||
### 3. 테이블 기능
|
||||
|
||||
- **페이지네이션**: 한 페이지당 표시 개수 설정 (10, 20, 50, 100)
|
||||
- **정렬**: 컬럼 클릭 시 오름차순/내림차순 정렬
|
||||
- **검색**: 전체 컬럼에서 키워드 검색
|
||||
- **자동 새로고침**: 설정된 시간마다 자동으로 데이터 갱신
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 구조 설계
|
||||
|
||||
### 파일 구조
|
||||
|
||||
```
|
||||
frontend/components/admin/dashboard/widgets/
|
||||
├── ListWidget.tsx # 메인 위젯 컴포넌트
|
||||
├── ListWidgetConfigModal.tsx # 설정 모달
|
||||
└── list-widget/
|
||||
├── ColumnSelector.tsx # 컬럼 선택 UI
|
||||
├── ManualColumnEditor.tsx # 수동 컬럼 편집 UI
|
||||
└── ListTable.tsx # 실제 테이블 렌더링
|
||||
```
|
||||
|
||||
### 데이터 타입 (`types.ts`에 추가)
|
||||
|
||||
```typescript
|
||||
// 리스트 위젯 설정
|
||||
export interface ListWidgetConfig {
|
||||
// 컬럼 설정 방식
|
||||
columnMode: "auto" | "manual"; // 자동 or 수동
|
||||
|
||||
// 컬럼 정의
|
||||
columns: ListColumn[];
|
||||
|
||||
// 테이블 옵션
|
||||
pageSize: number; // 페이지당 행 수 (기본: 10)
|
||||
enableSearch: boolean; // 검색 활성화 (기본: true)
|
||||
enableSort: boolean; // 정렬 활성화 (기본: true)
|
||||
enablePagination: boolean; // 페이지네이션 활성화 (기본: true)
|
||||
|
||||
// 스타일
|
||||
showHeader: boolean; // 헤더 표시 (기본: true)
|
||||
stripedRows: boolean; // 줄무늬 행 (기본: true)
|
||||
compactMode: boolean; // 압축 모드 (기본: false)
|
||||
}
|
||||
|
||||
// 리스트 컬럼
|
||||
export interface ListColumn {
|
||||
id: string; // 고유 ID
|
||||
label: string; // 표시될 컬럼명
|
||||
field: string; // 데이터 필드명
|
||||
width?: number; // 너비 (px)
|
||||
align?: "left" | "center" | "right"; // 정렬
|
||||
sortable?: boolean; // 정렬 가능 여부
|
||||
visible?: boolean; // 표시 여부
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 개발 단계
|
||||
|
||||
### Phase 1: 기본 구조 ✅ (예정)
|
||||
|
||||
- [ ] `ListWidget.tsx` 기본 컴포넌트 생성
|
||||
- [ ] `types.ts`에 타입 정의 추가
|
||||
- [ ] `DashboardSidebar.tsx`에 리스트 위젯 추가
|
||||
|
||||
### Phase 2: 데이터 소스 연동 ✅ (예정)
|
||||
|
||||
- [ ] 차트의 데이터 소스 로직 재사용
|
||||
- [ ] `ListWidgetConfigModal.tsx` 생성
|
||||
- Step 1: 데이터 소스 선택 (DB/API)
|
||||
- Step 2: 쿼리/API 설정 및 실행
|
||||
- Step 3: 컬럼 설정
|
||||
|
||||
### Phase 3: 컬럼 설정 UI ✅ (예정)
|
||||
|
||||
- [ ] `ColumnSelector.tsx`: 데이터 기반 자동 생성
|
||||
- 컬럼 선택 (체크박스)
|
||||
- 컬럼명 변경 (인라인 편집)
|
||||
- 순서 조정 (드래그 앤 드롭)
|
||||
- [ ] `ManualColumnEditor.tsx`: 수동 컬럼 정의
|
||||
- 컬럼 추가/삭제
|
||||
- 컬럼명 입력
|
||||
- 데이터 필드 매핑
|
||||
|
||||
### Phase 4: 테이블 렌더링 ✅ (예정)
|
||||
|
||||
- [ ] `ListTable.tsx` 구현
|
||||
- 기본 테이블 렌더링
|
||||
- 페이지네이션
|
||||
- 정렬 기능
|
||||
- 검색 기능
|
||||
- 반응형 디자인
|
||||
|
||||
### Phase 5: 자동 새로고침 ✅ (예정)
|
||||
|
||||
- [ ] 자동 새로고침 로직 구현 (차트와 동일)
|
||||
- [ ] 수동 새로고침 버튼 추가
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI/UX 설계
|
||||
|
||||
### 위젯 크기별 표시
|
||||
|
||||
- **작은 크기 (2x2)**: 컬럼 3개까지만 표시, 페이지네이션 간략화
|
||||
- **중간 크기 (3x3)**: 전체 기능 표시
|
||||
- **큰 크기 (4x4+)**: 더 많은 행 표시
|
||||
|
||||
### 설정 모달 플로우
|
||||
|
||||
```
|
||||
Step 1: 데이터 소스 선택
|
||||
├─ 데이터베이스
|
||||
│ ├─ 현재 DB
|
||||
│ └─ 외부 DB (커넥션 선택)
|
||||
└─ REST API
|
||||
|
||||
↓
|
||||
|
||||
Step 2: 데이터 가져오기
|
||||
├─ SQL 쿼리 작성 (DB인 경우)
|
||||
├─ API URL 설정 (API인 경우)
|
||||
└─ [실행] 버튼 클릭 → 데이터 미리보기
|
||||
|
||||
↓
|
||||
|
||||
Step 3: 컬럼 설정
|
||||
├─ 방식 선택: [자동 생성] / [수동 편집]
|
||||
├─ 컬럼 선택 및 설정
|
||||
├─ 테이블 옵션 설정
|
||||
└─ [저장] 버튼
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 참고 사항
|
||||
|
||||
### 차트와의 차이점
|
||||
|
||||
- **차트**: X축/Y축 매핑, 집계 함수, 그룹핑
|
||||
- **리스트**: 컬럼 선택, 정렬, 검색, 페이지네이션
|
||||
|
||||
### 재사용 가능한 컴포넌트
|
||||
|
||||
- `DataSourceSelector` (차트에서 사용 중)
|
||||
- `DatabaseConfig` (차트에서 사용 중)
|
||||
- `ApiConfig` (차트에서 사용 중)
|
||||
- `QueryEditor` (차트에서 사용 중)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 시작하기
|
||||
|
||||
1. `types.ts`에 타입 추가
|
||||
2. `ListWidget.tsx` 기본 구조 생성
|
||||
3. 사이드바에 위젯 추가
|
||||
4. 설정 모달 구현
|
||||
5. 테이블 렌더링 구현
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DashboardElement, QueryResult, ListWidgetConfig } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface ListWidgetProps {
|
||||
element: DashboardElement;
|
||||
onConfigUpdate?: (config: Partial<DashboardElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 위젯 컴포넌트
|
||||
* - DB 쿼리 또는 REST API로 데이터 가져오기
|
||||
* - 테이블 형태로 데이터 표시
|
||||
* - 페이지네이션, 정렬, 검색 기능
|
||||
*/
|
||||
export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||
const [data, setData] = useState<QueryResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const config = element.listConfig || {
|
||||
columnMode: "auto",
|
||||
viewMode: "table",
|
||||
columns: [],
|
||||
pageSize: 10,
|
||||
enablePagination: true,
|
||||
showHeader: true,
|
||||
stripedRows: true,
|
||||
compactMode: false,
|
||||
cardColumns: 3,
|
||||
};
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!element.dataSource || (!element.dataSource.query && !element.dataSource.endpoint)) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let queryResult: QueryResult;
|
||||
|
||||
// REST API vs Database 분기
|
||||
if (element.dataSource.type === "api" && element.dataSource.endpoint) {
|
||||
// REST API - 백엔드 프록시를 통한 호출
|
||||
const params = new URLSearchParams();
|
||||
if (element.dataSource.queryParams) {
|
||||
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: element.dataSource.endpoint,
|
||||
method: "GET",
|
||||
headers: element.dataSource.headers || {},
|
||||
queryParams: Object.fromEntries(params),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "외부 API 호출 실패");
|
||||
}
|
||||
|
||||
const apiData = result.data;
|
||||
|
||||
// JSON Path 처리
|
||||
let processedData = apiData;
|
||||
if (element.dataSource.jsonPath) {
|
||||
const paths = element.dataSource.jsonPath.split(".");
|
||||
for (const path of paths) {
|
||||
if (processedData && typeof processedData === "object" && path in processedData) {
|
||||
processedData = processedData[path];
|
||||
} else {
|
||||
throw new Error(`JSON Path "${element.dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Array.isArray(processedData) ? processedData : [processedData];
|
||||
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
||||
|
||||
queryResult = {
|
||||
columns,
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
executionTime: 0,
|
||||
};
|
||||
} else if (element.dataSource.query) {
|
||||
// Database (현재 DB 또는 외부 DB)
|
||||
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
||||
// 외부 DB
|
||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(element.dataSource.externalConnectionId),
|
||||
element.dataSource.query,
|
||||
);
|
||||
if (!externalResult.success) {
|
||||
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
||||
}
|
||||
queryResult = {
|
||||
columns: externalResult.data.columns,
|
||||
rows: externalResult.data.rows,
|
||||
totalRows: externalResult.data.rowCount,
|
||||
executionTime: 0,
|
||||
};
|
||||
} else {
|
||||
// 현재 DB
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
queryResult = {
|
||||
columns: result.columns,
|
||||
rows: result.rows,
|
||||
totalRows: result.rowCount,
|
||||
executionTime: 0,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw new Error("데이터 소스가 올바르게 설정되지 않았습니다");
|
||||
}
|
||||
|
||||
setData(queryResult);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
|
||||
// 자동 새로고침 설정
|
||||
const refreshInterval = element.dataSource?.refreshInterval;
|
||||
if (refreshInterval && refreshInterval > 0) {
|
||||
const interval = setInterval(loadData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [
|
||||
element.dataSource?.query,
|
||||
element.dataSource?.connectionType,
|
||||
element.dataSource?.externalConnectionId,
|
||||
element.dataSource?.endpoint,
|
||||
element.dataSource?.refreshInterval,
|
||||
]);
|
||||
|
||||
// 로딩 중
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
<div className="text-sm text-gray-600">데이터 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-2xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-red-600">오류 발생</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 또는 설정 없음
|
||||
if (!data || config.columns.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📋</div>
|
||||
<div className="text-sm font-medium text-gray-700">리스트를 설정하세요</div>
|
||||
<div className="mt-1 text-xs text-gray-500">⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(data.rows.length / config.pageSize);
|
||||
const startIdx = (currentPage - 1) * config.pageSize;
|
||||
const endIdx = startIdx + config.pageSize;
|
||||
const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">{element.title}</h3>
|
||||
</div>
|
||||
|
||||
{/* 테이블 뷰 */}
|
||||
{config.viewMode === "table" && (
|
||||
<div className={`flex-1 overflow-auto rounded-lg border ${config.compactMode ? "text-xs" : "text-sm"}`}>
|
||||
<Table>
|
||||
{config.showHeader && (
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{config.columns
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
<TableHead
|
||||
key={col.id}
|
||||
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
||||
style={{ width: col.width ? `${col.width}px` : undefined }}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
)}
|
||||
<TableBody>
|
||||
{paginatedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={config.columns.filter((col) => col.visible).length}
|
||||
className="text-center text-gray-500"
|
||||
>
|
||||
데이터가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedRows.map((row, idx) => (
|
||||
<TableRow key={idx} className={config.stripedRows ? undefined : ""}>
|
||||
{config.columns
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
<TableCell
|
||||
key={col.id}
|
||||
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
||||
>
|
||||
{String(row[col.field] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드 뷰 */}
|
||||
{config.viewMode === "card" && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{paginatedRows.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-500">데이터가 없습니다</div>
|
||||
) : (
|
||||
<div
|
||||
className={`grid gap-4 ${config.compactMode ? "text-xs" : "text-sm"}`}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${config.cardColumns || 3}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{paginatedRows.map((row, idx) => (
|
||||
<Card key={idx} className="p-4 transition-shadow hover:shadow-md">
|
||||
<div className="space-y-2">
|
||||
{config.columns
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
<div key={col.id}>
|
||||
<div className="text-xs font-medium text-gray-500">{col.label}</div>
|
||||
<div
|
||||
className={`font-medium text-gray-900 ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
|
||||
>
|
||||
{String(row[col.field] ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{config.enablePagination && totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<div className="text-gray-600">
|
||||
{startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<span className="text-gray-700">{currentPage}</span>
|
||||
<span className="text-gray-400">/</span>
|
||||
<span className="text-gray-500">{totalPages}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { DashboardElement, ChartDataSource, QueryResult, ListWidgetConfig, ListColumn } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ChevronLeft, ChevronRight, Save, X } from "lucide-react";
|
||||
import { DataSourceSelector } from "../data-sources/DataSourceSelector";
|
||||
import { DatabaseConfig } from "../data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "../data-sources/ApiConfig";
|
||||
import { QueryEditor } from "../QueryEditor";
|
||||
import { ColumnSelector } from "./list-widget/ColumnSelector";
|
||||
import { ManualColumnEditor } from "./list-widget/ManualColumnEditor";
|
||||
import { ListTableOptions } from "./list-widget/ListTableOptions";
|
||||
|
||||
interface ListWidgetConfigModalProps {
|
||||
isOpen: boolean;
|
||||
element: DashboardElement;
|
||||
onClose: () => void;
|
||||
onSave: (updates: Partial<DashboardElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 위젯 설정 모달
|
||||
* - 3단계 설정: 데이터 소스 → 데이터 가져오기 → 컬럼 설정
|
||||
*/
|
||||
export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: ListWidgetConfigModalProps) {
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
|
||||
const [title, setTitle] = useState(element.title || "📋 리스트");
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
||||
);
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [listConfig, setListConfig] = useState<ListWidgetConfig>(
|
||||
element.listConfig || {
|
||||
columnMode: "auto",
|
||||
viewMode: "table",
|
||||
columns: [],
|
||||
pageSize: 10,
|
||||
enablePagination: true,
|
||||
showHeader: true,
|
||||
stripedRows: true,
|
||||
compactMode: false,
|
||||
cardColumns: 3,
|
||||
},
|
||||
);
|
||||
|
||||
// 모달 열릴 때 element에서 설정 로드 (한 번만)
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// element가 변경되었을 때만 설정을 다시 로드
|
||||
setTitle(element.title || "📋 리스트");
|
||||
|
||||
// 기존 dataSource가 있으면 그대로 사용, 없으면 기본값
|
||||
if (element.dataSource) {
|
||||
setDataSource(element.dataSource);
|
||||
}
|
||||
|
||||
// 기존 listConfig가 있으면 그대로 사용, 없으면 기본값
|
||||
if (element.listConfig) {
|
||||
setListConfig(element.listConfig);
|
||||
}
|
||||
|
||||
// 현재 스텝은 1로 초기화
|
||||
setCurrentStep(1);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, element.id]); // element.id가 변경될 때만 재실행
|
||||
|
||||
// 데이터 소스 타입 변경
|
||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
||||
if (type === "database") {
|
||||
setDataSource((prev) => ({
|
||||
...prev,
|
||||
type: "database",
|
||||
connectionType: "current",
|
||||
}));
|
||||
} else {
|
||||
setDataSource((prev) => ({
|
||||
...prev,
|
||||
type: "api",
|
||||
method: "GET",
|
||||
}));
|
||||
}
|
||||
|
||||
// 데이터 소스 타입 변경 시에는 쿼리 결과만 초기화 (컬럼 설정은 유지)
|
||||
setQueryResult(null);
|
||||
}, []);
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 쿼리 실행 결과 처리
|
||||
const handleQueryTest = useCallback(
|
||||
(result: QueryResult) => {
|
||||
setQueryResult(result);
|
||||
|
||||
// 자동 모드이고 기존 컬럼이 없을 때만 자동 생성
|
||||
if (listConfig.columnMode === "auto" && result.columns.length > 0 && listConfig.columns.length === 0) {
|
||||
const autoColumns: ListColumn[] = result.columns.map((col, idx) => ({
|
||||
id: `col_${idx}`,
|
||||
label: col,
|
||||
field: col,
|
||||
align: "left",
|
||||
visible: true,
|
||||
}));
|
||||
setListConfig((prev) => ({ ...prev, columns: autoColumns }));
|
||||
}
|
||||
},
|
||||
[listConfig.columnMode, listConfig.columns.length],
|
||||
);
|
||||
|
||||
// 다음 단계
|
||||
const handleNext = () => {
|
||||
if (currentStep < 3) {
|
||||
setCurrentStep((prev) => (prev + 1) as 1 | 2 | 3);
|
||||
}
|
||||
};
|
||||
|
||||
// 이전 단계
|
||||
const handlePrev = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep((prev) => (prev - 1) as 1 | 2 | 3);
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
title,
|
||||
dataSource,
|
||||
listConfig,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 저장 가능 여부
|
||||
const canSave = queryResult && queryResult.rows.length > 0 && listConfig.columns.length > 0;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="flex max-h-[90vh] w-[90vw] max-w-6xl flex-col rounded-xl border bg-white shadow-2xl">
|
||||
{/* 헤더 */}
|
||||
<div className="space-y-4 border-b px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">📋 리스트 위젯 설정</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">데이터 소스와 컬럼을 설정하세요</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-gray-100">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* 제목 입력 */}
|
||||
<div>
|
||||
<Label htmlFor="list-title" className="text-sm font-medium">
|
||||
리스트 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="list-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 사용자 목록"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행 상태 표시 */}
|
||||
<div className="border-b bg-gray-50 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex items-center gap-2 ${currentStep >= 1 ? "text-blue-600" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 1 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span className="text-sm font-medium">데이터 소스</span>
|
||||
</div>
|
||||
<div className="h-0.5 w-12 bg-gray-300" />
|
||||
<div className={`flex items-center gap-2 ${currentStep >= 2 ? "text-blue-600" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 2 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span className="text-sm font-medium">데이터 가져오기</span>
|
||||
</div>
|
||||
<div className="h-0.5 w-12 bg-gray-300" />
|
||||
<div className={`flex items-center gap-2 ${currentStep >= 3 ? "text-blue-600" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 3 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<span className="text-sm font-medium">컬럼 설정</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{currentStep === 1 && (
|
||||
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* 왼쪽: 데이터 소스 설정 */}
|
||||
<div>
|
||||
{dataSource.type === "database" ? (
|
||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||
) : (
|
||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
||||
)}
|
||||
|
||||
{dataSource.type === "database" && (
|
||||
<div className="mt-4">
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceUpdate}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 데이터 미리보기 */}
|
||||
<div>
|
||||
{queryResult && queryResult.rows.length > 0 ? (
|
||||
<div className="rounded-lg border bg-gray-50 p-4">
|
||||
<h3 className="mb-3 font-semibold text-gray-800">📋 데이터 미리보기</h3>
|
||||
<div className="overflow-x-auto rounded bg-white p-3">
|
||||
<Badge variant="secondary" className="mb-2">
|
||||
{queryResult.totalRows}개 데이터
|
||||
</Badge>
|
||||
<pre className="text-xs text-gray-700">
|
||||
{JSON.stringify(queryResult.rows.slice(0, 3), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||
<div>
|
||||
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 미리보기가 표시됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && queryResult && (
|
||||
<div className="space-y-6">
|
||||
{listConfig.columnMode === "auto" ? (
|
||||
<ColumnSelector
|
||||
availableColumns={queryResult.columns}
|
||||
selectedColumns={listConfig.columns}
|
||||
sampleData={queryResult.rows[0]}
|
||||
onChange={(columns) => setListConfig((prev) => ({ ...prev, columns }))}
|
||||
/>
|
||||
) : (
|
||||
<ManualColumnEditor
|
||||
availableFields={queryResult.columns}
|
||||
columns={listConfig.columns}
|
||||
onChange={(columns) => setListConfig((prev) => ({ ...prev, columns }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ListTableOptions
|
||||
config={listConfig}
|
||||
onChange={(updates) => setListConfig((prev) => ({ ...prev, ...updates }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex items-center justify-between border-t bg-gray-50 p-6">
|
||||
<div>
|
||||
{queryResult && (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
📊 {queryResult.rows.length}개 데이터 로드됨
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{currentStep > 1 && (
|
||||
<Button variant="outline" onClick={handlePrev}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
{currentStep < 3 ? (
|
||||
<Button onClick={handleNext} disabled={currentStep === 2 && !queryResult}>
|
||||
다음
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSave} disabled={!canSave}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ListColumn } from "../../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { GripVertical } from "lucide-react";
|
||||
|
||||
interface ColumnSelectorProps {
|
||||
availableColumns: string[];
|
||||
selectedColumns: ListColumn[];
|
||||
sampleData: Record<string, any>;
|
||||
onChange: (columns: ListColumn[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 선택 컴포넌트 (자동 모드)
|
||||
* - 쿼리 결과에서 컬럼 선택
|
||||
* - 컬럼명 변경
|
||||
* - 정렬, 너비, 정렬 방향 설정
|
||||
*/
|
||||
export function ColumnSelector({ availableColumns, selectedColumns, sampleData, onChange }: ColumnSelectorProps) {
|
||||
// 컬럼 선택/해제
|
||||
const handleToggle = (field: string) => {
|
||||
const exists = selectedColumns.find((col) => col.field === field);
|
||||
if (exists) {
|
||||
onChange(selectedColumns.filter((col) => col.field !== field));
|
||||
} else {
|
||||
const newCol: ListColumn = {
|
||||
id: `col_${selectedColumns.length}`,
|
||||
label: field,
|
||||
field,
|
||||
align: "left",
|
||||
visible: true,
|
||||
};
|
||||
onChange([...selectedColumns, newCol]);
|
||||
}
|
||||
};
|
||||
|
||||
// 컬럼 라벨 변경
|
||||
const handleLabelChange = (field: string, label: string) => {
|
||||
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, label } : col)));
|
||||
};
|
||||
|
||||
// 정렬 방향 변경
|
||||
const handleAlignChange = (field: string, align: "left" | "center" | "right") => {
|
||||
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, align } : col)));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">컬럼 선택 및 설정</h3>
|
||||
<p className="text-sm text-gray-600">표시할 컬럼을 선택하고 이름을 변경하세요</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{availableColumns.map((field) => {
|
||||
const selectedCol = selectedColumns.find((col) => col.field === field);
|
||||
const isSelected = !!selectedCol;
|
||||
const preview = sampleData[field];
|
||||
const previewText =
|
||||
preview !== undefined && preview !== null
|
||||
? typeof preview === "object"
|
||||
? JSON.stringify(preview).substring(0, 30)
|
||||
: String(preview).substring(0, 30)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field}
|
||||
className={`rounded-lg border p-4 transition-colors ${
|
||||
isSelected ? "border-blue-300 bg-blue-50" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-start gap-3">
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => handleToggle(field)} className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||
<span className="font-medium text-gray-700">{field}</span>
|
||||
{previewText && <span className="text-xs text-gray-500">(예: {previewText})</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelected && selectedCol && (
|
||||
<div className="ml-7 grid grid-cols-2 gap-3">
|
||||
{/* 컬럼명 */}
|
||||
<div>
|
||||
<Label className="text-xs">표시 이름</Label>
|
||||
<Input
|
||||
value={selectedCol.label}
|
||||
onChange={(e) => handleLabelChange(field, e.target.value)}
|
||||
placeholder="컬럼명"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방향 */}
|
||||
<div>
|
||||
<Label className="text-xs">정렬</Label>
|
||||
<Select
|
||||
value={selectedCol.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleAlignChange(field, value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedColumns.length === 0 && (
|
||||
<div className="mt-4 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-center text-sm text-yellow-700">
|
||||
⚠️ 최소 1개 이상의 컬럼을 선택해주세요
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ListWidgetConfig } from "../../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ListTableOptionsProps {
|
||||
config: ListWidgetConfig;
|
||||
onChange: (updates: Partial<ListWidgetConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 테이블 옵션 설정 컴포넌트
|
||||
* - 페이지 크기, 검색, 정렬 등 설정
|
||||
*/
|
||||
export function ListTableOptions({ config, onChange }: ListTableOptionsProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">테이블 옵션</h3>
|
||||
<p className="text-sm text-gray-600">테이블 동작과 스타일을 설정하세요</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 뷰 모드 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">뷰 모드</Label>
|
||||
<RadioGroup
|
||||
value={config.viewMode}
|
||||
onValueChange={(value: "table" | "card") => onChange({ viewMode: value })}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="table" id="table" />
|
||||
<Label htmlFor="table" className="cursor-pointer font-normal">
|
||||
📊 테이블 (기본)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="card" id="card" />
|
||||
<Label htmlFor="card" className="cursor-pointer font-normal">
|
||||
🗂️ 카드
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 모드 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">컬럼 설정 방식</Label>
|
||||
<RadioGroup
|
||||
value={config.columnMode}
|
||||
onValueChange={(value: "auto" | "manual") => onChange({ columnMode: value })}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="auto" id="auto" />
|
||||
<Label htmlFor="auto" className="cursor-pointer font-normal">
|
||||
자동 (쿼리 결과에서 선택)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="manual" id="manual" />
|
||||
<Label htmlFor="manual" className="cursor-pointer font-normal">
|
||||
수동 (직접 추가 및 매핑)
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 카드 뷰 컬럼 수 */}
|
||||
{config.viewMode === "card" && (
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">카드 컬럼 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="6"
|
||||
value={config.cardColumns || 3}
|
||||
onChange={(e) => onChange({ cardColumns: parseInt(e.target.value) || 3 })}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">한 줄에 표시할 카드 개수 (1-6)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지 크기 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">페이지당 행 수</Label>
|
||||
<Select value={String(config.pageSize)} onValueChange={(value) => onChange({ pageSize: parseInt(value) })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5개</SelectItem>
|
||||
<SelectItem value="10">10개</SelectItem>
|
||||
<SelectItem value="20">20개</SelectItem>
|
||||
<SelectItem value="50">50개</SelectItem>
|
||||
<SelectItem value="100">100개</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 기능 활성화 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">기능</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enablePagination"
|
||||
checked={config.enablePagination}
|
||||
onCheckedChange={(checked) => onChange({ enablePagination: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="enablePagination" className="cursor-pointer font-normal">
|
||||
페이지네이션
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 스타일 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">스타일</Label>
|
||||
<div className="space-y-2">
|
||||
{config.viewMode === "table" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showHeader"
|
||||
checked={config.showHeader}
|
||||
onCheckedChange={(checked) => onChange({ showHeader: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="showHeader" className="cursor-pointer font-normal">
|
||||
헤더 표시
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="stripedRows"
|
||||
checked={config.stripedRows}
|
||||
onCheckedChange={(checked) => onChange({ stripedRows: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="stripedRows" className="cursor-pointer font-normal">
|
||||
줄무늬 행
|
||||
</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="compactMode"
|
||||
checked={config.compactMode}
|
||||
onCheckedChange={(checked) => onChange({ compactMode: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="compactMode" className="cursor-pointer font-normal">
|
||||
압축 모드 (작은 크기)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ListColumn } from "../../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Trash2, GripVertical } from "lucide-react";
|
||||
|
||||
interface ManualColumnEditorProps {
|
||||
availableFields: string[];
|
||||
columns: ListColumn[];
|
||||
onChange: (columns: ListColumn[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 컬럼 편집 컴포넌트
|
||||
* - 사용자가 직접 컬럼 추가/삭제
|
||||
* - 컬럼명과 데이터 필드 직접 매핑
|
||||
*/
|
||||
export function ManualColumnEditor({ availableFields, columns, onChange }: ManualColumnEditorProps) {
|
||||
// 새 컬럼 추가
|
||||
const handleAddColumn = () => {
|
||||
const newCol: ListColumn = {
|
||||
id: `col_${Date.now()}`,
|
||||
label: `컬럼 ${columns.length + 1}`,
|
||||
field: availableFields[0] || "",
|
||||
align: "left",
|
||||
visible: true,
|
||||
};
|
||||
onChange([...columns, newCol]);
|
||||
};
|
||||
|
||||
// 컬럼 삭제
|
||||
const handleRemove = (id: string) => {
|
||||
onChange(columns.filter((col) => col.id !== id));
|
||||
};
|
||||
|
||||
// 컬럼 속성 업데이트
|
||||
const handleUpdate = (id: string, updates: Partial<ListColumn>) => {
|
||||
onChange(columns.map((col) => (col.id === id ? { ...col, ...updates } : col)));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">수동 컬럼 편집</h3>
|
||||
<p className="text-sm text-gray-600">직접 컬럼을 추가하고 데이터 필드를 매핑하세요</p>
|
||||
</div>
|
||||
<Button onClick={handleAddColumn} size="sm" className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
컬럼 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{columns.map((col, index) => (
|
||||
<div key={col.id} className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||
<span className="font-medium text-gray-700">컬럼 {index + 1}</span>
|
||||
<Button
|
||||
onClick={() => handleRemove(col.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ml-auto text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* 컬럼명 */}
|
||||
<div>
|
||||
<Label className="text-xs">표시 이름 *</Label>
|
||||
<Input
|
||||
value={col.label}
|
||||
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
|
||||
placeholder="예: 사용자 이름"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 데이터 필드 */}
|
||||
<div>
|
||||
<Label className="text-xs">데이터 필드 *</Label>
|
||||
<Select value={col.field} onValueChange={(value) => handleUpdate(col.id, { field: value })}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableFields.map((field) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{field}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방향 */}
|
||||
<div>
|
||||
<Label className="text-xs">정렬</Label>
|
||||
<Select
|
||||
value={col.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 너비 */}
|
||||
<div>
|
||||
<Label className="text-xs">너비 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={col.width || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdate(col.id, { width: e.target.value ? parseInt(e.target.value) : undefined })
|
||||
}
|
||||
placeholder="자동"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{columns.length === 0 && (
|
||||
<div className="mt-4 rounded-lg border border-gray-300 bg-gray-100 p-8 text-center">
|
||||
<div className="text-sm text-gray-600">컬럼을 추가하여 시작하세요</div>
|
||||
<Button onClick={handleAddColumn} size="sm" className="mt-3 gap-2">
|
||||
<Plus className="h-4 w-4" />첫 번째 컬럼 추가
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
'use client';
|
||||
"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';
|
||||
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[];
|
||||
|
|
@ -23,36 +23,60 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval }: Dash
|
|||
|
||||
// 개별 요소 데이터 로딩
|
||||
const loadElementData = useCallback(async (element: DashboardElement) => {
|
||||
if (!element.dataSource?.query || element.type !== 'chart') {
|
||||
if (!element.dataSource?.query || element.type !== "chart") {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingElements(prev => new Set([...prev, element.id]));
|
||||
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
|
||||
}));
|
||||
let result;
|
||||
|
||||
// 외부 DB vs 현재 DB 분기
|
||||
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
||||
// 외부 DB
|
||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(element.dataSource.externalConnectionId),
|
||||
element.dataSource.query,
|
||||
);
|
||||
|
||||
if (!externalResult.success) {
|
||||
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
||||
}
|
||||
|
||||
const data: QueryResult = {
|
||||
columns: externalResult.data?.[0] ? Object.keys(externalResult.data[0]) : [],
|
||||
rows: externalResult.data || [],
|
||||
totalRows: externalResult.data?.length || 0,
|
||||
executionTime: 0,
|
||||
};
|
||||
|
||||
setElementData((prev) => ({
|
||||
...prev,
|
||||
[element.id]: data,
|
||||
}));
|
||||
} else {
|
||||
// 현재 DB
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
|
||||
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 => {
|
||||
setLoadingElements((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(element.id);
|
||||
return newSet;
|
||||
|
|
@ -63,11 +87,11 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval }: Dash
|
|||
// 모든 요소 데이터 로딩
|
||||
const loadAllData = useCallback(async () => {
|
||||
setLastRefresh(new Date());
|
||||
|
||||
const chartElements = elements.filter(el => el.type === 'chart' && el.dataSource?.query);
|
||||
|
||||
|
||||
const chartElements = elements.filter((el) => el.type === "chart" && el.dataSource?.query);
|
||||
|
||||
// 병렬로 모든 차트 데이터 로딩
|
||||
await Promise.all(chartElements.map(element => loadElementData(element)));
|
||||
await Promise.all(chartElements.map((element) => loadElementData(element)));
|
||||
}, [elements, loadElementData]);
|
||||
|
||||
// 초기 데이터 로딩
|
||||
|
|
@ -88,34 +112,28 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval }: Dash
|
|||
// 요소가 없는 경우
|
||||
if (elements.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-gray-50">
|
||||
<div className="flex h-full 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 className="mb-4 text-6xl">📊</div>
|
||||
<div className="mb-2 text-xl font-medium text-gray-700">표시할 요소가 없습니다</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="relative h-full w-full overflow-auto bg-gray-100">
|
||||
{/* 새로고침 상태 표시 */}
|
||||
<div className="absolute top-4 right-4 z-10 bg-white rounded-lg shadow-sm px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground absolute top-4 right-4 z-10 rounded-lg bg-white px-3 py-2 text-xs shadow-sm">
|
||||
마지막 업데이트: {lastRefresh.toLocaleTimeString()}
|
||||
{Array.from(loadingElements).length > 0 && (
|
||||
<span className="ml-2 text-primary">
|
||||
({Array.from(loadingElements).length}개 로딩 중...)
|
||||
</span>
|
||||
<span className="text-primary ml-2">({Array.from(loadingElements).length}개 로딩 중...)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 대시보드 요소들 */}
|
||||
<div className="relative" style={{ minHeight: '100%' }}>
|
||||
<div className="relative" style={{ minHeight: "100%" }}>
|
||||
{elements.map((element) => (
|
||||
<ViewerElement
|
||||
key={element.id}
|
||||
|
|
@ -145,32 +163,32 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
|
|||
|
||||
return (
|
||||
<div
|
||||
className="absolute bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"
|
||||
className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||
style={{
|
||||
left: element.position.x,
|
||||
top: element.position.y,
|
||||
width: element.size.width,
|
||||
height: element.size.height
|
||||
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>
|
||||
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800">{element.title}</h3>
|
||||
|
||||
{/* 새로고침 버튼 (호버 시에만 표시) */}
|
||||
{isHovered && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="text-gray-400 hover:text-muted-foreground disabled:opacity-50"
|
||||
className="hover:text-muted-foreground text-gray-400 disabled:opacity-50"
|
||||
title="새로고침"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="w-4 h-4 border border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
|
||||
) : (
|
||||
'🔄'
|
||||
"🔄"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -178,20 +196,15 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
|
|||
|
||||
{/* 내용 */}
|
||||
<div className="h-[calc(100%-57px)]">
|
||||
{element.type === 'chart' ? (
|
||||
<ChartRenderer
|
||||
element={element}
|
||||
data={data}
|
||||
width={element.size.width}
|
||||
height={element.size.height - 57}
|
||||
/>
|
||||
{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="flex h-full w-full items-center justify-center bg-gradient-to-br from-blue-400 to-purple-600 p-4 text-white">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl mb-2">
|
||||
{element.subtype === 'exchange' && '💱'}
|
||||
{element.subtype === 'weather' && '☁️'}
|
||||
<div className="mb-2 text-3xl">
|
||||
{element.subtype === "exchange" && "💱"}
|
||||
{element.subtype === "weather" && "☁️"}
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-line">{element.content}</div>
|
||||
</div>
|
||||
|
|
@ -201,10 +214,10 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
|
|||
|
||||
{/* 로딩 오버레이 */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center">
|
||||
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-white">
|
||||
<div className="text-center">
|
||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<div className="text-sm text-muted-foreground">업데이트 중...</div>
|
||||
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<div className="text-muted-foreground text-sm">업데이트 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -218,53 +231,73 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
|
|||
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');
|
||||
|
||||
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'];
|
||||
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) },
|
||||
{ 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'];
|
||||
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) },
|
||||
{ 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'];
|
||||
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) },
|
||||
{
|
||||
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'];
|
||||
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) },
|
||||
{ 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) },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.86.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
|
|
@ -39,6 +40,7 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"docx": "^9.5.1",
|
||||
"docx-preview": "^0.3.6",
|
||||
|
|
@ -4764,6 +4766,47 @@
|
|||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "3",
|
||||
"d3-axis": "3",
|
||||
"d3-brush": "3",
|
||||
"d3-chord": "3",
|
||||
"d3-color": "3",
|
||||
"d3-contour": "4",
|
||||
"d3-delaunay": "6",
|
||||
"d3-dispatch": "3",
|
||||
"d3-drag": "3",
|
||||
"d3-dsv": "3",
|
||||
"d3-ease": "3",
|
||||
"d3-fetch": "3",
|
||||
"d3-force": "3",
|
||||
"d3-format": "3",
|
||||
"d3-geo": "3",
|
||||
"d3-hierarchy": "3",
|
||||
"d3-interpolate": "3",
|
||||
"d3-path": "3",
|
||||
"d3-polygon": "3",
|
||||
"d3-quadtree": "3",
|
||||
"d3-random": "3",
|
||||
"d3-scale": "4",
|
||||
"d3-scale-chromatic": "3",
|
||||
"d3-selection": "3",
|
||||
"d3-shape": "3",
|
||||
"d3-time": "3",
|
||||
"d3-time-format": "4",
|
||||
"d3-timer": "3",
|
||||
"d3-transition": "3",
|
||||
"d3-zoom": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
|
|
@ -4776,6 +4819,43 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-axis": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
||||
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-brush": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
||||
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "3",
|
||||
"d3-transition": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-chord": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
||||
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
|
|
@ -4785,6 +4865,30 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-contour": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
||||
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"delaunator": "5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
|
|
@ -4807,6 +4911,40 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dsv": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"iconv-lite": "0.6",
|
||||
"rw": "1"
|
||||
},
|
||||
"bin": {
|
||||
"csv2json": "bin/dsv2json.js",
|
||||
"csv2tsv": "bin/dsv2dsv.js",
|
||||
"dsv2dsv": "bin/dsv2dsv.js",
|
||||
"dsv2json": "bin/dsv2json.js",
|
||||
"json2csv": "bin/json2dsv.js",
|
||||
"json2dsv": "bin/json2dsv.js",
|
||||
"json2tsv": "bin/json2dsv.js",
|
||||
"tsv2csv": "bin/dsv2dsv.js",
|
||||
"tsv2json": "bin/dsv2json.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dsv/node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
|
|
@ -4816,6 +4954,32 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-fetch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
||||
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dsv": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-quadtree": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
|
|
@ -4825,6 +4989,27 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-hierarchy": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
|
|
@ -4846,6 +5031,33 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-polygon": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
||||
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-quadtree": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-random": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
||||
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
||||
"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",
|
||||
|
|
@ -4862,6 +5074,19 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
|
|
@ -5130,6 +5355,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delaunator": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
|
||||
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
|
|
@ -9028,6 +9262,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
||||
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
|
|
@ -9058,6 +9298,12 @@
|
|||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.86.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
|
|
@ -47,6 +48,7 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"docx": "^9.5.1",
|
||||
"docx-preview": "^0.3.6",
|
||||
|
|
|
|||
Loading…
Reference in New Issue