rest api 기능 구현
This commit is contained in:
parent
2e84c4272f
commit
593983d6ee
|
|
@ -27,6 +27,7 @@
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"mysql2": "^3.15.0",
|
"mysql2": "^3.15.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"oracledb": "^6.9.0",
|
"oracledb": "^6.9.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
|
@ -48,6 +49,7 @@
|
||||||
"@types/multer": "^1.4.13",
|
"@types/multer": "^1.4.13",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/node-fetch": "^2.6.13",
|
||||||
"@types/nodemailer": "^6.4.20",
|
"@types/nodemailer": "^6.4.20",
|
||||||
"@types/oracledb": "^6.9.1",
|
"@types/oracledb": "^6.9.1",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
|
|
@ -3380,6 +3382,17 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/nodemailer": {
|
||||||
"version": "6.4.20",
|
"version": "6.4.20",
|
||||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.20.tgz",
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.20.tgz",
|
||||||
|
|
@ -8116,6 +8129,26 @@
|
||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
|
|
@ -9861,6 +9894,12 @@
|
||||||
"nodetouch": "bin/nodetouch.js"
|
"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": {
|
"node_modules/triple-beam": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
|
||||||
|
|
@ -10237,6 +10276,22 @@
|
||||||
"makeerror": "1.0.12"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"mysql2": "^3.15.0",
|
"mysql2": "^3.15.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"oracledb": "^6.9.0",
|
"oracledb": "^6.9.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
|
@ -62,6 +63,7 @@
|
||||||
"@types/multer": "^1.4.13",
|
"@types/multer": "^1.4.13",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/node-fetch": "^2.6.13",
|
||||||
"@types/nodemailer": "^6.4.20",
|
"@types/nodemailer": "^6.4.20",
|
||||||
"@types/oracledb": "^6.9.1",
|
"@types/oracledb": "^6.9.1",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { Response } from 'express';
|
import { Response } from "express";
|
||||||
import { AuthenticatedRequest } from '../middleware/authMiddleware';
|
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||||
import { DashboardService } from '../services/DashboardService';
|
import { DashboardService } from "../services/DashboardService";
|
||||||
import { CreateDashboardRequest, UpdateDashboardRequest, DashboardListQuery } from '../types/dashboard';
|
import {
|
||||||
import { PostgreSQLService } from '../database/PostgreSQLService';
|
CreateDashboardRequest,
|
||||||
|
UpdateDashboardRequest,
|
||||||
|
DashboardListQuery,
|
||||||
|
} from "../types/dashboard";
|
||||||
|
import { PostgreSQLService } from "../database/PostgreSQLService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 컨트롤러
|
* 대시보드 컨트롤러
|
||||||
|
|
@ -10,29 +14,38 @@ import { PostgreSQLService } from '../database/PostgreSQLService';
|
||||||
* - 요청 검증 및 응답 포맷팅
|
* - 요청 검증 및 응답 포맷팅
|
||||||
*/
|
*/
|
||||||
export class DashboardController {
|
export class DashboardController {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 생성
|
* 대시보드 생성
|
||||||
* POST /api/dashboards
|
* POST /api/dashboards
|
||||||
*/
|
*/
|
||||||
async createDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
|
async createDashboard(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '인증이 필요합니다.'
|
message: "인증이 필요합니다.",
|
||||||
});
|
});
|
||||||
return;
|
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) {
|
if (!title || title.trim().length === 0) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '대시보드 제목이 필요합니다.'
|
message: "대시보드 제목이 필요합니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +53,7 @@ export class DashboardController {
|
||||||
if (!elements || !Array.isArray(elements)) {
|
if (!elements || !Array.isArray(elements)) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '대시보드 요소 데이터가 필요합니다.'
|
message: "대시보드 요소 데이터가 필요합니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -49,7 +62,7 @@ export class DashboardController {
|
||||||
if (title.length > 200) {
|
if (title.length > 200) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '제목은 200자를 초과할 수 없습니다.'
|
message: "제목은 200자를 초과할 수 없습니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +71,7 @@ export class DashboardController {
|
||||||
if (description && description.length > 1000) {
|
if (description && description.length > 1000) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '설명은 1000자를 초과할 수 없습니다.'
|
message: "설명은 1000자를 초과할 수 없습니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -69,21 +82,23 @@ export class DashboardController {
|
||||||
isPublic,
|
isPublic,
|
||||||
elements,
|
elements,
|
||||||
tags,
|
tags,
|
||||||
category
|
category,
|
||||||
};
|
};
|
||||||
|
|
||||||
// console.log('대시보드 생성 시작:', { title: dashboardData.title, userId, elementsCount: elements.length });
|
// console.log('대시보드 생성 시작:', { title: dashboardData.title, userId, elementsCount: elements.length });
|
||||||
|
|
||||||
const savedDashboard = await DashboardService.createDashboard(dashboardData, userId);
|
const savedDashboard = await DashboardService.createDashboard(
|
||||||
|
dashboardData,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
// console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title });
|
// console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title });
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: savedDashboard,
|
data: savedDashboard,
|
||||||
message: '대시보드가 성공적으로 생성되었습니다.'
|
message: "대시보드가 성공적으로 생성되었습니다.",
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// console.error('Dashboard creation error:', {
|
// console.error('Dashboard creation error:', {
|
||||||
// message: error?.message,
|
// message: error?.message,
|
||||||
|
|
@ -92,8 +107,9 @@ export class DashboardController {
|
||||||
// });
|
// });
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: error?.message || '대시보드 생성 중 오류가 발생했습니다.',
|
message: error?.message || "대시보드 생성 중 오류가 발생했습니다.",
|
||||||
error: process.env.NODE_ENV === 'development' ? error?.message : undefined
|
error:
|
||||||
|
process.env.NODE_ENV === "development" ? error?.message : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -111,15 +127,20 @@ export class DashboardController {
|
||||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100), // 최대 100개
|
limit: Math.min(parseInt(req.query.limit as string) || 20, 100), // 최대 100개
|
||||||
search: req.query.search as string,
|
search: req.query.search as string,
|
||||||
category: req.query.category as string,
|
category: req.query.category as string,
|
||||||
isPublic: req.query.isPublic === 'true' ? true : req.query.isPublic === 'false' ? false : undefined,
|
isPublic:
|
||||||
createdBy: req.query.createdBy as string
|
req.query.isPublic === "true"
|
||||||
|
? true
|
||||||
|
: req.query.isPublic === "false"
|
||||||
|
? false
|
||||||
|
: undefined,
|
||||||
|
createdBy: req.query.createdBy as string,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 페이지 번호 유효성 검증
|
// 페이지 번호 유효성 검증
|
||||||
if (query.page! < 1) {
|
if (query.page! < 1) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '페이지 번호는 1 이상이어야 합니다.'
|
message: "페이지 번호는 1 이상이어야 합니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -129,15 +150,17 @@ export class DashboardController {
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result.dashboards,
|
data: result.dashboards,
|
||||||
pagination: result.pagination
|
pagination: result.pagination,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('Dashboard list error:', error);
|
// console.error('Dashboard list error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '대시보드 목록 조회 중 오류가 발생했습니다.',
|
message: "대시보드 목록 조회 중 오류가 발생했습니다.",
|
||||||
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
error:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? (error as Error).message
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +177,7 @@ export class DashboardController {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '대시보드 ID가 필요합니다.'
|
message: "대시보드 ID가 필요합니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -164,7 +187,7 @@ export class DashboardController {
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '대시보드를 찾을 수 없거나 접근 권한이 없습니다.'
|
message: "대시보드를 찾을 수 없거나 접근 권한이 없습니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -176,15 +199,17 @@ export class DashboardController {
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: dashboard
|
data: dashboard,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('Dashboard get error:', error);
|
// console.error('Dashboard get error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '대시보드 조회 중 오류가 발생했습니다.',
|
message: "대시보드 조회 중 오류가 발생했습니다.",
|
||||||
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
error:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? (error as Error).message
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +218,10 @@ export class DashboardController {
|
||||||
* 대시보드 수정
|
* 대시보드 수정
|
||||||
* PUT /api/dashboards/:id
|
* PUT /api/dashboards/:id
|
||||||
*/
|
*/
|
||||||
async updateDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
|
async updateDashboard(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
|
|
@ -201,7 +229,7 @@ export class DashboardController {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '인증이 필요합니다.'
|
message: "인증이 필요합니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -209,7 +237,7 @@ export class DashboardController {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '대시보드 ID가 필요합니다.'
|
message: "대시보드 ID가 필요합니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -218,37 +246,48 @@ export class DashboardController {
|
||||||
|
|
||||||
// 유효성 검증
|
// 유효성 검증
|
||||||
if (updateData.title !== undefined) {
|
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({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '올바른 제목을 입력해주세요.'
|
message: "올바른 제목을 입력해주세요.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (updateData.title.length > 200) {
|
if (updateData.title.length > 200) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '제목은 200자를 초과할 수 없습니다.'
|
message: "제목은 200자를 초과할 수 없습니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateData.title = updateData.title.trim();
|
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({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '설명은 1000자를 초과할 수 없습니다.'
|
message: "설명은 1000자를 초과할 수 없습니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedDashboard = await DashboardService.updateDashboard(id, updateData, userId);
|
const updatedDashboard = await DashboardService.updateDashboard(
|
||||||
|
id,
|
||||||
|
updateData,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
if (!updatedDashboard) {
|
if (!updatedDashboard) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '대시보드를 찾을 수 없거나 수정 권한이 없습니다.'
|
message: "대시보드를 찾을 수 없거나 수정 권한이 없습니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -256,24 +295,26 @@ export class DashboardController {
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: updatedDashboard,
|
data: updatedDashboard,
|
||||||
message: '대시보드가 성공적으로 수정되었습니다.'
|
message: "대시보드가 성공적으로 수정되었습니다.",
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('Dashboard update error:', error);
|
// console.error('Dashboard update error:', error);
|
||||||
|
|
||||||
if ((error as Error).message.includes('권한이 없습니다')) {
|
if ((error as Error).message.includes("권한이 없습니다")) {
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: (error as Error).message
|
message: (error as Error).message,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '대시보드 수정 중 오류가 발생했습니다.',
|
message: "대시보드 수정 중 오류가 발생했습니다.",
|
||||||
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
error:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? (error as Error).message
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -282,7 +323,10 @@ export class DashboardController {
|
||||||
* 대시보드 삭제
|
* 대시보드 삭제
|
||||||
* DELETE /api/dashboards/:id
|
* DELETE /api/dashboards/:id
|
||||||
*/
|
*/
|
||||||
async deleteDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
|
async deleteDashboard(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
|
|
@ -290,7 +334,7 @@ export class DashboardController {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '인증이 필요합니다.'
|
message: "인증이 필요합니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -298,7 +342,7 @@ export class DashboardController {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '대시보드 ID가 필요합니다.'
|
message: "대시보드 ID가 필요합니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -308,22 +352,24 @@ export class DashboardController {
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '대시보드를 찾을 수 없거나 삭제 권한이 없습니다.'
|
message: "대시보드를 찾을 수 없거나 삭제 권한이 없습니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: '대시보드가 성공적으로 삭제되었습니다.'
|
message: "대시보드가 성공적으로 삭제되었습니다.",
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('Dashboard delete error:', error);
|
// console.error('Dashboard delete error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '대시보드 삭제 중 오류가 발생했습니다.',
|
message: "대시보드 삭제 중 오류가 발생했습니다.",
|
||||||
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
error:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? (error as Error).message
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -332,14 +378,17 @@ export class DashboardController {
|
||||||
* 내 대시보드 목록 조회
|
* 내 대시보드 목록 조회
|
||||||
* GET /api/dashboards/my
|
* GET /api/dashboards/my
|
||||||
*/
|
*/
|
||||||
async getMyDashboards(req: AuthenticatedRequest, res: Response): Promise<void> {
|
async getMyDashboards(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '인증이 필요합니다.'
|
message: "인증이 필요합니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -349,7 +398,7 @@ export class DashboardController {
|
||||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||||
search: req.query.search as string,
|
search: req.query.search as string,
|
||||||
category: req.query.category as string,
|
category: req.query.category as string,
|
||||||
createdBy: userId // 본인이 만든 대시보드만
|
createdBy: userId, // 본인이 만든 대시보드만
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await DashboardService.getDashboards(query, userId);
|
const result = await DashboardService.getDashboards(query, userId);
|
||||||
|
|
@ -357,15 +406,17 @@ export class DashboardController {
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result.dashboards,
|
data: result.dashboards,
|
||||||
pagination: result.pagination
|
pagination: result.pagination,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('My dashboards error:', error);
|
// console.error('My dashboards error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '내 대시보드 목록 조회 중 오류가 발생했습니다.',
|
message: "내 대시보드 목록 조회 중 오류가 발생했습니다.",
|
||||||
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
error:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? (error as Error).message
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -389,20 +440,20 @@ export class DashboardController {
|
||||||
const { query } = req.body;
|
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({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '쿼리가 필요합니다.'
|
message: "쿼리가 필요합니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQL 인젝션 방지를 위한 기본적인 검증
|
// SQL 인젝션 방지를 위한 기본적인 검증
|
||||||
const trimmedQuery = query.trim().toLowerCase();
|
const trimmedQuery = query.trim().toLowerCase();
|
||||||
if (!trimmedQuery.startsWith('select')) {
|
if (!trimmedQuery.startsWith("select")) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'SELECT 쿼리만 허용됩니다.'
|
message: "SELECT 쿼리만 허용됩니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -411,7 +462,7 @@ export class DashboardController {
|
||||||
const result = await PostgreSQLService.query(query.trim());
|
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 || [];
|
const rows = result.rows || [];
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
|
|
@ -419,17 +470,80 @@ export class DashboardController {
|
||||||
data: {
|
data: {
|
||||||
columns,
|
columns,
|
||||||
rows,
|
rows,
|
||||||
rowCount: rows.length
|
rowCount: rows.length,
|
||||||
},
|
},
|
||||||
message: '쿼리가 성공적으로 실행되었습니다.'
|
message: "쿼리가 성공적으로 실행되었습니다.",
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('Query execution error:', error);
|
// console.error('Query execution error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '쿼리 실행 중 오류가 발생했습니다.',
|
message: "쿼리 실행 중 오류가 발생했습니다.",
|
||||||
error: process.env.NODE_ENV === 'development' ? (error as Error).message : '쿼리 실행 오류'
|
error:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? (error as Error).message
|
||||||
|
: "쿼리 실행 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 API 프록시 (CORS 우회용)
|
||||||
|
* POST /api/dashboards/fetch-external-api
|
||||||
|
*/
|
||||||
|
async fetchExternalApi(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { url, method = "GET", headers = {}, queryParams = {} } = req.body;
|
||||||
|
|
||||||
|
if (!url || typeof url !== "string") {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "URL이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 쿼리 파라미터 추가
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
Object.entries(queryParams).forEach(([key, value]) => {
|
||||||
|
if (key && value) {
|
||||||
|
urlObj.searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 외부 API 호출
|
||||||
|
const fetch = (await import("node-fetch")).default;
|
||||||
|
const response = await fetch(urlObj.toString(), {
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`외부 API 오류: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "외부 API 호출 중 오류가 발생했습니다.",
|
||||||
|
error:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? (error as Error).message
|
||||||
|
: "외부 API 호출 오류",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Router } from 'express';
|
import { Router } from "express";
|
||||||
import { DashboardController } from '../controllers/DashboardController';
|
import { DashboardController } from "../controllers/DashboardController";
|
||||||
import { authenticateToken } from '../middleware/authMiddleware';
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const dashboardController = new DashboardController();
|
const dashboardController = new DashboardController();
|
||||||
|
|
@ -13,25 +13,49 @@ const dashboardController = new DashboardController();
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 공개 대시보드 목록 조회 (인증 불필요)
|
// 공개 대시보드 목록 조회 (인증 불필요)
|
||||||
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.use(authenticateToken);
|
||||||
|
|
||||||
// 내 대시보드 목록 조회
|
// 내 대시보드 목록 조회
|
||||||
router.get('/my', dashboardController.getMyDashboards.bind(dashboardController));
|
router.get(
|
||||||
|
"/my",
|
||||||
|
dashboardController.getMyDashboards.bind(dashboardController)
|
||||||
|
);
|
||||||
|
|
||||||
// 대시보드 CRUD
|
// 대시보드 CRUD
|
||||||
router.post('/', dashboardController.createDashboard.bind(dashboardController));
|
router.post("/", dashboardController.createDashboard.bind(dashboardController));
|
||||||
router.get('/', dashboardController.getDashboards.bind(dashboardController));
|
router.get("/", dashboardController.getDashboards.bind(dashboardController));
|
||||||
router.get('/:id', dashboardController.getDashboard.bind(dashboardController));
|
router.get("/:id", dashboardController.getDashboard.bind(dashboardController));
|
||||||
router.put('/:id', dashboardController.updateDashboard.bind(dashboardController));
|
router.put(
|
||||||
router.delete('/:id', dashboardController.deleteDashboard.bind(dashboardController));
|
"/:id",
|
||||||
|
dashboardController.updateDashboard.bind(dashboardController)
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/:id",
|
||||||
|
dashboardController.deleteDashboard.bind(dashboardController)
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ interface ChartConfigPanelProps {
|
||||||
queryResult?: QueryResult;
|
queryResult?: QueryResult;
|
||||||
onConfigChange: (config: ChartConfig) => void;
|
onConfigChange: (config: ChartConfig) => void;
|
||||||
chartType?: string;
|
chartType?: string;
|
||||||
|
dataSourceType?: "database" | "api"; // 데이터 소스 타입
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -25,11 +26,18 @@ interface ChartConfigPanelProps {
|
||||||
* - 차트 스타일 설정
|
* - 차트 스타일 설정
|
||||||
* - 실시간 미리보기
|
* - 실시간 미리보기
|
||||||
*/
|
*/
|
||||||
export function ChartConfigPanel({ config, queryResult, onConfigChange, chartType }: ChartConfigPanelProps) {
|
export function ChartConfigPanel({
|
||||||
|
config,
|
||||||
|
queryResult,
|
||||||
|
onConfigChange,
|
||||||
|
chartType,
|
||||||
|
dataSourceType,
|
||||||
|
}: ChartConfigPanelProps) {
|
||||||
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
||||||
|
|
||||||
// 원형/도넛 차트는 Y축이 필수가 아님
|
// 원형/도넛 차트 또는 REST API는 Y축이 필수가 아님
|
||||||
const isPieChart = chartType === "pie" || chartType === "donut";
|
const isPieChart = chartType === "pie" || chartType === "donut";
|
||||||
|
const isApiSource = dataSourceType === "api";
|
||||||
|
|
||||||
// 설정 업데이트
|
// 설정 업데이트
|
||||||
const updateConfig = useCallback(
|
const updateConfig = useCallback(
|
||||||
|
|
@ -41,15 +49,91 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange, chartTyp
|
||||||
[currentConfig, onConfigChange],
|
[currentConfig, onConfigChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 사용 가능한 컬럼 목록
|
// 사용 가능한 컬럼 목록 및 타입 정보
|
||||||
const availableColumns = queryResult?.columns || [];
|
const availableColumns = queryResult?.columns || [];
|
||||||
|
const columnTypes = queryResult?.columnTypes || {};
|
||||||
const sampleData = queryResult?.rows?.[0] || {};
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 데이터 필드 매핑 */}
|
{/* 데이터 필드 매핑 */}
|
||||||
{queryResult && (
|
{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>
|
||||||
|
|
||||||
|
{/* 컬럼 타입 정보 */}
|
||||||
|
{Object.keys(columnTypes).length > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="mb-2 text-xs font-medium text-gray-700">컬럼 타입 분석:</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{availableColumns.map((col) => {
|
||||||
|
const type = columnTypes[col];
|
||||||
|
const isComplex = type === "object" || type === "array";
|
||||||
|
return (
|
||||||
|
<Badge key={col} variant={isComplex ? "destructive" : "secondary"} className="text-xs">
|
||||||
|
{col}: {type}
|
||||||
|
{isComplex && " ⚠️"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</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">
|
<div className="space-y-2">
|
||||||
<Label>차트 제목</Label>
|
<Label>차트 제목</Label>
|
||||||
|
|
@ -74,29 +158,111 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange, chartTyp
|
||||||
<SelectValue placeholder="선택하세요" />
|
<SelectValue placeholder="선택하세요" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{availableColumns.map((col) => (
|
{simpleColumns.map((col) => {
|
||||||
|
const type = columnTypes[col] || "unknown";
|
||||||
|
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}>
|
<SelectItem key={col} value={col}>
|
||||||
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
|
<span className="flex items-center gap-2">
|
||||||
|
<span>{col}</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{type}
|
||||||
|
</Badge>
|
||||||
|
{previewText && <span className="text-xs text-gray-500">(예: {previewText})</span>}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{simpleColumns.length === 0 && (
|
||||||
|
<p className="text-xs text-red-500">⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Y축 설정 (다중 선택 가능) */}
|
{/* Y축 설정 (다중 선택 가능) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>
|
<Label>
|
||||||
Y축 (값) - 여러 개 선택 가능
|
Y축 (값) - 여러 개 선택 가능
|
||||||
{!isPieChart && <span className="ml-1 text-red-500">*</span>}
|
{!isPieChart && !isApiSource && <span className="ml-1 text-red-500">*</span>}
|
||||||
{isPieChart && <span className="ml-2 text-xs text-gray-500">(선택사항)</span>}
|
{(isPieChart || isApiSource) && (
|
||||||
|
<span className="ml-2 text-xs text-gray-500">(선택사항 - 그룹핑+집계 사용 가능)</span>
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Card className="max-h-60 overflow-y-auto p-3">
|
<Card className="max-h-60 overflow-y-auto p-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{availableColumns.map((col) => {
|
{/* 숫자 타입 우선 표시 */}
|
||||||
|
{numericColumns.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 text-xs font-medium text-green-700">✅ 숫자 타입 (권장)</div>
|
||||||
|
{numericColumns.map((col) => {
|
||||||
const isSelected = Array.isArray(currentConfig.yAxis)
|
const isSelected = Array.isArray(currentConfig.yAxis)
|
||||||
? currentConfig.yAxis.includes(col)
|
? currentConfig.yAxis.includes(col)
|
||||||
: currentConfig.yAxis === 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>
|
||||||
|
<Badge variant="outline" className="ml-2 bg-green-100 text-xs">
|
||||||
|
number
|
||||||
|
</Badge>
|
||||||
|
{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;
|
||||||
|
const type = columnTypes[col];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={col} className="flex items-center gap-2 rounded p-2 hover:bg-gray-50">
|
<div key={col} className="flex items-center gap-2 rounded p-2 hover:bg-gray-50">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|
@ -115,7 +281,6 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange, chartTyp
|
||||||
newYAxis = currentYAxis.filter((c) => c !== col);
|
newYAxis = currentYAxis.filter((c) => c !== col);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 단일 값이면 문자열로, 다중 값이면 배열로
|
|
||||||
if (newYAxis.length === 1) {
|
if (newYAxis.length === 1) {
|
||||||
newYAxis = newYAxis[0];
|
newYAxis = newYAxis[0];
|
||||||
}
|
}
|
||||||
|
|
@ -125,13 +290,25 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange, chartTyp
|
||||||
/>
|
/>
|
||||||
<Label className="flex-1 cursor-pointer text-sm font-normal">
|
<Label className="flex-1 cursor-pointer text-sm font-normal">
|
||||||
{col}
|
{col}
|
||||||
{sampleData[col] && <span className="ml-2 text-xs text-gray-500">(예: {sampleData[col]})</span>}
|
<Badge variant="outline" className="ml-2 text-xs">
|
||||||
|
{type}
|
||||||
|
</Badge>
|
||||||
|
{sampleData[col] !== undefined && (
|
||||||
|
<span className="ml-2 text-xs text-gray-500">
|
||||||
|
(예: {String(sampleData[col]).substring(0, 30)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
{simpleColumns.length === 0 && (
|
||||||
|
<p className="text-xs text-red-500">⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||||
|
)}
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)
|
팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -279,10 +456,22 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange, chartTyp
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 필수 필드 확인 */}
|
{/* 필수 필드 확인 */}
|
||||||
{(!currentConfig.xAxis || !currentConfig.yAxis) && (
|
{!currentConfig.xAxis && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertDescription>X축과 Y축을 모두 설정해야 차트가 표시됩니다.</AlertDescription>
|
<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>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -112,12 +112,21 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
}
|
}
|
||||||
|
|
||||||
// 저장 가능 여부 확인
|
// 저장 가능 여부 확인
|
||||||
|
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||||
|
const isApiSource = dataSource.type === "api";
|
||||||
|
|
||||||
const canSave =
|
const canSave =
|
||||||
currentStep === 2 &&
|
currentStep === 2 &&
|
||||||
queryResult &&
|
queryResult &&
|
||||||
queryResult.rows.length > 0 &&
|
queryResult.rows.length > 0 &&
|
||||||
chartConfig.xAxis &&
|
chartConfig.xAxis &&
|
||||||
(chartConfig.yAxis || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
|
(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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
|
@ -182,6 +191,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
queryResult={queryResult}
|
queryResult={queryResult}
|
||||||
onConfigChange={handleChartConfigChange}
|
onConfigChange={handleChartConfigChange}
|
||||||
chartType={element.subtype}
|
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 className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
||||||
|
|
||||||
// REST API vs Database 분기
|
// REST API vs Database 분기
|
||||||
if (element.dataSource.type === "api" && element.dataSource.endpoint) {
|
if (element.dataSource.type === "api" && element.dataSource.endpoint) {
|
||||||
// REST API
|
// REST API - 백엔드 프록시를 통한 호출 (CORS 우회)
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (element.dataSource.queryParams) {
|
if (element.dataSource.queryParams) {
|
||||||
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
||||||
|
|
@ -55,27 +55,30 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = element.dataSource.endpoint;
|
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||||
const queryString = params.toString();
|
method: "POST",
|
||||||
if (queryString) {
|
headers: {
|
||||||
url += (url.includes("?") ? "&" : "?") + queryString;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...element.dataSource.headers,
|
},
|
||||||
};
|
body: JSON.stringify({
|
||||||
|
url: element.dataSource.endpoint,
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers,
|
headers: element.dataSource.headers || {},
|
||||||
|
queryParams: Object.fromEntries(params),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiData = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || "외부 API 호출 실패");
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiData = result.data;
|
||||||
|
|
||||||
// JSON Path 처리
|
// JSON Path 처리
|
||||||
let processedData = apiData;
|
let processedData = apiData;
|
||||||
|
|
@ -187,7 +190,11 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터나 설정이 없으면
|
// 데이터나 설정이 없으면
|
||||||
if (!chartData || !element.chartConfig?.xAxis || !element.chartConfig?.yAxis) {
|
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||||
|
const isApiSource = element.dataSource?.type === "api";
|
||||||
|
const needsYAxis = !(isPieChart || isApiSource) || (!element.chartConfig?.aggregation && !element.chartConfig?.yAxis);
|
||||||
|
|
||||||
|
if (!chartData || !element.chartConfig?.xAxis || (needsYAxis && !element.chartConfig?.yAxis)) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
|
||||||
|
|
@ -91,30 +91,31 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL 구성
|
// 백엔드 프록시를 통한 외부 API 호출 (CORS 우회)
|
||||||
let url = dataSource.endpoint;
|
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||||
const queryString = params.toString();
|
method: "POST",
|
||||||
if (queryString) {
|
headers: {
|
||||||
url += (url.includes("?") ? "&" : "?") + queryString;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 헤더 구성
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...dataSource.headers,
|
},
|
||||||
};
|
body: JSON.stringify({
|
||||||
|
url: dataSource.endpoint,
|
||||||
// 외부 API 직접 호출
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers,
|
headers: dataSource.headers || {},
|
||||||
|
queryParams: Object.fromEntries(params),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiData = await response.json();
|
const apiResponse = await response.json();
|
||||||
|
|
||||||
|
if (!apiResponse.success) {
|
||||||
|
throw new Error(apiResponse.message || "외부 API 호출 실패");
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiData = apiResponse.data;
|
||||||
|
|
||||||
// JSON Path 처리
|
// JSON Path 처리
|
||||||
let data = apiData;
|
let data = apiData;
|
||||||
|
|
@ -132,18 +133,43 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
// 배열이 아니면 배열로 변환
|
// 배열이 아니면 배열로 변환
|
||||||
const rows = Array.isArray(data) ? data : [data];
|
const rows = Array.isArray(data) ? data : [data];
|
||||||
|
|
||||||
// 컬럼 추출
|
if (rows.length === 0) {
|
||||||
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
throw new Error("API 응답에 데이터가 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
const result: QueryResult = {
|
// 컬럼 추출 및 타입 분석
|
||||||
|
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,
|
columns,
|
||||||
rows,
|
rows,
|
||||||
totalRows: rows.length,
|
totalRows: rows.length,
|
||||||
executionTime: 0,
|
executionTime: 0,
|
||||||
|
columnTypes, // 타입 정보 추가
|
||||||
};
|
};
|
||||||
|
|
||||||
setTestResult(result);
|
setTestResult(queryResult);
|
||||||
onTestResult?.(result);
|
onTestResult?.(queryResult);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다";
|
const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다";
|
||||||
setTestError(errorMessage);
|
setTestError(errorMessage);
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ export interface QueryResult {
|
||||||
totalRows: number; // 전체 행 수
|
totalRows: number; // 전체 행 수
|
||||||
executionTime: number; // 실행 시간 (ms)
|
executionTime: number; // 실행 시간 (ms)
|
||||||
error?: string; // 오류 메시지
|
error?: string; // 오류 메시지
|
||||||
|
columnTypes?: Record<string, string>; // 각 컬럼의 타입 정보 (number, string, object, array 등)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 시계 위젯 설정
|
// 시계 위젯 설정
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,45 @@ export function transformQueryResultToChartData(queryResult: QueryResult, config
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let rows = queryResult.rows;
|
||||||
|
|
||||||
|
// 그룹핑 처리
|
||||||
|
if (config.groupBy && config.groupBy !== "__none__") {
|
||||||
|
rows = applyGrouping(rows, config.groupBy, config.aggregation, config.yAxis);
|
||||||
|
}
|
||||||
|
|
||||||
// X축 라벨 추출
|
// X축 라벨 추출
|
||||||
const labels = queryResult.rows.map((row) => String(row[config.xAxis!] || ""));
|
const labels = rows.map((row) => String(row[config.xAxis!] || ""));
|
||||||
|
|
||||||
// Y축 데이터 추출
|
// Y축 데이터 추출
|
||||||
const yAxisFields = Array.isArray(config.yAxis) ? config.yAxis : config.yAxis ? [config.yAxis] : [];
|
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) {
|
if (yAxisFields.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 각 Y축 필드에 대해 데이터셋 생성
|
// 각 Y축 필드에 대해 데이터셋 생성
|
||||||
const datasets: ChartDataset[] = yAxisFields.map((field, index) => {
|
const datasets: ChartDataset[] = yAxisFields.map((field, index) => {
|
||||||
const data = queryResult.rows.map((row) => {
|
const data = rows.map((row) => {
|
||||||
const value = row[field];
|
const value = row[field];
|
||||||
return typeof value === "number" ? value : parseFloat(String(value)) || 0;
|
return typeof value === "number" ? value : parseFloat(String(value)) || 0;
|
||||||
});
|
});
|
||||||
|
|
@ -38,6 +64,73 @@ export function transformQueryResultToChartData(queryResult: QueryResult, config
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹핑 및 집계 처리
|
||||||
|
*/
|
||||||
|
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 응답을 차트 데이터로 변환
|
* API 응답을 차트 데이터로 변환
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue