diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 7a96aaa2..46d2fea5 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -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", diff --git a/backend-node/package.json b/backend-node/package.json index 1f96f8e5..a6744ac6 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -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", diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index d35c102b..cf8f3cc2 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -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 { + async createDashboard( + req: AuthenticatedRequest, + res: Response + ): Promise { 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 { 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 { + async updateDashboard( + req: AuthenticatedRequest, + res: Response + ): Promise { 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 { + async deleteDashboard( + req: AuthenticatedRequest, + res: Response + ): Promise { 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 { + async getMyDashboards( + req: AuthenticatedRequest, + res: Response + ): Promise { 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 + : "쿼리 실행 오류", }); } } -} \ No newline at end of file + + /** + * 외부 API 프록시 (CORS 우회용) + * POST /api/dashboards/fetch-external-api + */ + async fetchExternalApi( + req: AuthenticatedRequest, + res: Response + ): Promise { + 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 호출 오류", + }); + } + } +} diff --git a/backend-node/src/routes/dashboardRoutes.ts b/backend-node/src/routes/dashboardRoutes.ts index e6b5714d..7ed7d634 100644 --- a/backend-node/src/routes/dashboardRoutes.ts +++ b/backend-node/src/routes/dashboardRoutes.ts @@ -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; diff --git a/frontend/components/admin/dashboard/ChartConfigPanel.tsx b/frontend/components/admin/dashboard/ChartConfigPanel.tsx index 135a4855..297a37da 100644 --- a/frontend/components/admin/dashboard/ChartConfigPanel.tsx +++ b/frontend/components/admin/dashboard/ChartConfigPanel.tsx @@ -17,6 +17,7 @@ interface ChartConfigPanelProps { queryResult?: QueryResult; onConfigChange: (config: ChartConfig) => void; 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(config || {}); - // 원형/도넛 차트는 Y축이 필수가 아님 + // 원형/도넛 차트 또는 REST API는 Y축이 필수가 아님 const isPieChart = chartType === "pie" || chartType === "donut"; + const isApiSource = dataSourceType === "api"; // 설정 업데이트 const updateConfig = useCallback( @@ -41,15 +49,91 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange, chartTyp [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 (
{/* 데이터 필드 매핑 */} {queryResult && ( <> + {/* API 응답 미리보기 */} + {queryResult.rows && queryResult.rows.length > 0 && ( + +
+ +

📋 API 응답 데이터 미리보기

+
+
+
총 {queryResult.totalRows}개 데이터 중 첫 번째 행:
+
{JSON.stringify(sampleData, null, 2)}
+
+ + {/* 컬럼 타입 정보 */} + {Object.keys(columnTypes).length > 0 && ( +
+
컬럼 타입 분석:
+
+ {availableColumns.map((col) => { + const type = columnTypes[col]; + const isComplex = type === "object" || type === "array"; + return ( + + {col}: {type} + {isComplex && " ⚠️"} + + ); + })} +
+
+ )} +
+ )} + + {/* 복잡한 타입 경고 */} + {complexColumns.length > 0 && ( + + + +
⚠️ 차트에 사용할 수 없는 컬럼 감지
+
+ 다음 컬럼은 객체 또는 배열 타입이라서 차트 축으로 선택할 수 없습니다: +
+ {complexColumns.map((col) => ( + + {col} ({columnTypes[col]}) + + ))} +
+
+
+ 💡 해결 방법: JSON Path를 사용하여 중첩된 객체 내부의 값을 직접 추출하세요. +
+ 예: main 또는{" "} + data.items +
+
+
+ )} + {/* 차트 제목 */}
@@ -74,64 +158,157 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange, chartTyp - {availableColumns.map((col) => ( - - {col} {sampleData[col] && `(예: ${sampleData[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 ( + + + {col} + + {type} + + {previewText && (예: {previewText})} + + + ); + })} + {simpleColumns.length === 0 && ( +

⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.

+ )}
{/* Y축 설정 (다중 선택 가능) */}
- {availableColumns.map((col) => { - const isSelected = Array.isArray(currentConfig.yAxis) - ? currentConfig.yAxis.includes(col) - : currentConfig.yAxis === col; + {/* 숫자 타입 우선 표시 */} + {numericColumns.length > 0 && ( + <> +
✅ 숫자 타입 (권장)
+ {numericColumns.map((col) => { + const isSelected = Array.isArray(currentConfig.yAxis) + ? currentConfig.yAxis.includes(col) + : currentConfig.yAxis === col; - return ( -
- { - const currentYAxis = Array.isArray(currentConfig.yAxis) - ? currentConfig.yAxis - : currentConfig.yAxis - ? [currentConfig.yAxis] - : []; + return ( +
+ { + 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); - } + let newYAxis: string | string[]; + if (checked) { + newYAxis = [...currentYAxis, col]; + } else { + newYAxis = currentYAxis.filter((c) => c !== col); + } - // 단일 값이면 문자열로, 다중 값이면 배열로 - if (newYAxis.length === 1) { - newYAxis = newYAxis[0]; - } + if (newYAxis.length === 1) { + newYAxis = newYAxis[0]; + } - updateConfig({ yAxis: newYAxis }); - }} - /> - -
- ); - })} + updateConfig({ yAxis: newYAxis }); + }} + /> + +
+ ); + })} + + )} + + {/* 기타 간단한 타입 */} + {simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && ( + <> + {numericColumns.length > 0 &&
} +
📝 기타 타입
+ {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 ( +
+ { + 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 }); + }} + /> + +
+ ); + })} + + )}
+ {simpleColumns.length === 0 && ( +

⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.

+ )}

팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)

@@ -279,10 +456,22 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange, chartTyp {/* 필수 필드 확인 */} - {(!currentConfig.xAxis || !currentConfig.yAxis) && ( + {!currentConfig.xAxis && ( - X축과 Y축을 모두 설정해야 차트가 표시됩니다. + X축은 필수입니다. + + )} + {!isPieChart && !isApiSource && !currentConfig.yAxis && ( + + + Y축을 설정해야 차트가 표시됩니다. + + )} + {(isPieChart || isApiSource) && !currentConfig.yAxis && !currentConfig.aggregation && ( + + + Y축 또는 집계 함수(COUNT 등)를 설정해야 차트가 표시됩니다. )} diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index 99eea6b4..ccf1e94e 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -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 = currentStep === 2 && queryResult && queryResult.rows.length > 0 && 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 (
@@ -182,6 +191,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element queryResult={queryResult} onConfigChange={handleChartConfigChange} chartType={element.subtype} + dataSourceType={dataSource.type} /> ) : (
diff --git a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx index 8b94ec50..3a675f5d 100644 --- a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx +++ b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx @@ -45,7 +45,7 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char // REST API vs Database 분기 if (element.dataSource.type === "api" && element.dataSource.endpoint) { - // REST API + // REST API - 백엔드 프록시를 통한 호출 (CORS 우회) const params = new URLSearchParams(); if (element.dataSource.queryParams) { 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 queryString = params.toString(); - if (queryString) { - url += (url.includes("?") ? "&" : "?") + queryString; - } - - const headers: Record = { - "Content-Type": "application/json", - ...element.dataSource.headers, - }; - - const response = await fetch(url, { - method: "GET", - headers, + 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 apiData = await response.json(); + const result = await response.json(); + + if (!result.success) { + throw new Error(result.message || "외부 API 호출 실패"); + } + + const apiData = result.data; // JSON Path 처리 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 (
diff --git a/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx index ae7e8294..2784def6 100644 --- a/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx @@ -91,30 +91,31 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps }); } - // URL 구성 - let url = dataSource.endpoint; - const queryString = params.toString(); - if (queryString) { - url += (url.includes("?") ? "&" : "?") + queryString; - } - - // 헤더 구성 - const headers: Record = { - "Content-Type": "application/json", - ...dataSource.headers, - }; - - // 외부 API 직접 호출 - const response = await fetch(url, { - method: "GET", - headers, + // 백엔드 프록시를 통한 외부 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 apiData = await response.json(); + const apiResponse = await response.json(); + + if (!apiResponse.success) { + throw new Error(apiResponse.message || "외부 API 호출 실패"); + } + + const apiData = apiResponse.data; // JSON Path 처리 let data = apiData; @@ -132,18 +133,43 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps // 배열이 아니면 배열로 변환 const rows = Array.isArray(data) ? data : [data]; - // 컬럼 추출 - const columns = rows.length > 0 ? Object.keys(rows[0]) : []; + if (rows.length === 0) { + throw new Error("API 응답에 데이터가 없습니다"); + } - const result: QueryResult = { + // 컬럼 추출 및 타입 분석 + const firstRow = rows[0]; + const columns = Object.keys(firstRow); + + // 각 컬럼의 타입 분석 + const columnTypes: Record = {}; + 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(result); - onTestResult?.(result); + setTestResult(queryResult); + onTestResult?.(queryResult); } catch (err) { const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다"; setTestError(errorMessage); diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 121692b3..bc9a3b5e 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -128,6 +128,7 @@ export interface QueryResult { totalRows: number; // 전체 행 수 executionTime: number; // 실행 시간 (ms) error?: string; // 오류 메시지 + columnTypes?: Record; // 각 컬럼의 타입 정보 (number, string, object, array 등) } // 시계 위젯 설정 diff --git a/frontend/components/admin/dashboard/utils/chartDataTransform.ts b/frontend/components/admin/dashboard/utils/chartDataTransform.ts index cc15ec01..e5b3e30a 100644 --- a/frontend/components/admin/dashboard/utils/chartDataTransform.ts +++ b/frontend/components/admin/dashboard/utils/chartDataTransform.ts @@ -8,19 +8,45 @@ export function transformQueryResultToChartData(queryResult: QueryResult, config return null; } + let rows = queryResult.rows; + + // 그룹핑 처리 + if (config.groupBy && config.groupBy !== "__none__") { + rows = applyGrouping(rows, config.groupBy, config.aggregation, config.yAxis); + } + // X축 라벨 추출 - const labels = queryResult.rows.map((row) => String(row[config.xAxis!] || "")); + 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 = queryResult.rows.map((row) => { + const data = rows.map((row) => { const value = row[field]; return typeof value === "number" ? value : parseFloat(String(value)) || 0; }); @@ -38,6 +64,73 @@ export function transformQueryResultToChartData(queryResult: QueryResult, config }; } +/** + * 그룹핑 및 집계 처리 + */ +function applyGrouping( + rows: Record[], + groupByField: string, + aggregation?: "sum" | "avg" | "count" | "max" | "min", + yAxis?: string | string[], +): Record[] { + // 그룹별로 데이터 묶기 + const groups = new Map[]>(); + + rows.forEach((row) => { + const key = String(row[groupByField] || ""); + if (!groups.has(key)) { + groups.set(key, []); + } + groups.get(key)!.push(row); + }); + + // 각 그룹에 대해 집계 수행 + const aggregatedRows: Record[] = []; + + groups.forEach((groupRows, key) => { + const aggregatedRow: Record = { + [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 응답을 차트 데이터로 변환 */