차트 구현 및 리스트 구현 #98

Merged
hyeonsu merged 17 commits from feature/dashboard into main 2025-10-15 12:00:23 +09:00
42 changed files with 6847 additions and 1567 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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 호출 오류",
});
}
}
}

View File

@ -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;

View File

@ -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(),
}
);
}

View File

@ -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`: 데이터 변환 유틸리티
- 데이터 페칭 및 자동 새로고침

View File

@ -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">

View File

@ -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>
)}
</>
)}

View File

@ -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 "위젯 내용이 여기에 표시됩니다";
}

View File

@ -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>

View File

@ -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>

View File

@ -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 },
];
}

View File

@ -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" }} />;
}

View File

@ -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>
);
}

View File

@ -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" }} />;
}

View File

@ -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>
);
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}

View File

@ -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} />;
}

View File

@ -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>
);
}

View File

@ -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} />;
}

View File

@ -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" }} />;
}

View File

@ -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>
);
}

View File

@ -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" }} />;
}

View File

@ -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>
);
}

View File

@ -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" }} />;
}

View File

@ -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";

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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";
}

View File

@ -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)
}

View File

@ -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);
}

View File

@ -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. 테이블 렌더링 구현

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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) },
];
}

View File

@ -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",

View File

@ -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",