diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index a4bf97ba..6e058a32 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -48,6 +48,7 @@ "@types/pg": "^8.15.5", "@types/sanitize-html": "^2.9.5", "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "eslint": "^8.55.0", @@ -3544,6 +3545,13 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index 2caf0d1c..eea6b877 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -66,6 +66,7 @@ "@types/pg": "^8.15.5", "@types/sanitize-html": "^2.9.5", "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "eslint": "^8.55.0", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 3c8974d4..260c69f8 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -43,6 +43,7 @@ import entityReferenceRoutes from "./routes/entityReferenceRoutes"; import externalCallRoutes from "./routes/externalCallRoutes"; import externalCallConfigRoutes from "./routes/externalCallConfigRoutes"; import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes"; +import dashboardRoutes from "./routes/dashboardRoutes"; import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -171,6 +172,7 @@ app.use("/api/entity-reference", entityReferenceRoutes); app.use("/api/external-calls", externalCallRoutes); app.use("/api/external-call-configs", externalCallConfigRoutes); app.use("/api/dataflow", dataflowExecutionRoutes); +app.use("/api/dashboards", dashboardRoutes); // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts new file mode 100644 index 00000000..d35c102b --- /dev/null +++ b/backend-node/src/controllers/DashboardController.ts @@ -0,0 +1,436 @@ +import { Response } from 'express'; +import { AuthenticatedRequest } from '../middleware/authMiddleware'; +import { DashboardService } from '../services/DashboardService'; +import { CreateDashboardRequest, UpdateDashboardRequest, DashboardListQuery } from '../types/dashboard'; +import { PostgreSQLService } from '../database/PostgreSQLService'; + +/** + * 대시보드 컨트롤러 + * - REST API 엔드포인트 처리 + * - 요청 검증 및 응답 포맷팅 + */ +export class DashboardController { + + /** + * 대시보드 생성 + * POST /api/dashboards + */ + async createDashboard(req: AuthenticatedRequest, res: Response): Promise { + try { + const userId = req.user?.userId; + if (!userId) { + res.status(401).json({ + success: false, + message: '인증이 필요합니다.' + }); + return; + } + + const { title, description, elements, isPublic = false, tags, category }: CreateDashboardRequest = req.body; + + // 유효성 검증 + if (!title || title.trim().length === 0) { + res.status(400).json({ + success: false, + message: '대시보드 제목이 필요합니다.' + }); + return; + } + + if (!elements || !Array.isArray(elements)) { + res.status(400).json({ + success: false, + message: '대시보드 요소 데이터가 필요합니다.' + }); + return; + } + + // 제목 길이 체크 + if (title.length > 200) { + res.status(400).json({ + success: false, + message: '제목은 200자를 초과할 수 없습니다.' + }); + return; + } + + // 설명 길이 체크 + if (description && description.length > 1000) { + res.status(400).json({ + success: false, + message: '설명은 1000자를 초과할 수 없습니다.' + }); + return; + } + + const dashboardData: CreateDashboardRequest = { + title: title.trim(), + description: description?.trim(), + isPublic, + elements, + tags, + category + }; + + // console.log('대시보드 생성 시작:', { title: dashboardData.title, userId, elementsCount: elements.length }); + + const savedDashboard = await DashboardService.createDashboard(dashboardData, userId); + + // console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title }); + + res.status(201).json({ + success: true, + data: savedDashboard, + message: '대시보드가 성공적으로 생성되었습니다.' + }); + + } catch (error: any) { + // console.error('Dashboard creation error:', { + // message: error?.message, + // stack: error?.stack, + // error + // }); + res.status(500).json({ + success: false, + message: error?.message || '대시보드 생성 중 오류가 발생했습니다.', + error: process.env.NODE_ENV === 'development' ? error?.message : undefined + }); + } + } + + /** + * 대시보드 목록 조회 + * GET /api/dashboards + */ + async getDashboards(req: AuthenticatedRequest, res: Response): Promise { + try { + const userId = req.user?.userId; + + const query: DashboardListQuery = { + page: parseInt(req.query.page as string) || 1, + limit: Math.min(parseInt(req.query.limit as string) || 20, 100), // 최대 100개 + search: req.query.search as string, + category: req.query.category as string, + isPublic: req.query.isPublic === 'true' ? true : req.query.isPublic === 'false' ? false : undefined, + createdBy: req.query.createdBy as string + }; + + // 페이지 번호 유효성 검증 + if (query.page! < 1) { + res.status(400).json({ + success: false, + message: '페이지 번호는 1 이상이어야 합니다.' + }); + return; + } + + const result = await DashboardService.getDashboards(query, userId); + + res.json({ + success: true, + data: result.dashboards, + pagination: result.pagination + }); + + } catch (error) { + // console.error('Dashboard list error:', error); + res.status(500).json({ + success: false, + message: '대시보드 목록 조회 중 오류가 발생했습니다.', + error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + }); + } + } + + /** + * 대시보드 상세 조회 + * GET /api/dashboards/:id + */ + async getDashboard(req: AuthenticatedRequest, res: Response): Promise { + try { + const { id } = req.params; + const userId = req.user?.userId; + + if (!id) { + res.status(400).json({ + success: false, + message: '대시보드 ID가 필요합니다.' + }); + return; + } + + const dashboard = await DashboardService.getDashboardById(id, userId); + + if (!dashboard) { + res.status(404).json({ + success: false, + message: '대시보드를 찾을 수 없거나 접근 권한이 없습니다.' + }); + return; + } + + // 조회수 증가 (본인이 만든 대시보드가 아닌 경우에만) + if (userId && dashboard.createdBy !== userId) { + await DashboardService.incrementViewCount(id); + } + + res.json({ + success: true, + data: dashboard + }); + + } catch (error) { + // console.error('Dashboard get error:', error); + res.status(500).json({ + success: false, + message: '대시보드 조회 중 오류가 발생했습니다.', + error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + }); + } + } + + /** + * 대시보드 수정 + * PUT /api/dashboards/:id + */ + async updateDashboard(req: AuthenticatedRequest, res: Response): Promise { + try { + const { id } = req.params; + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: '인증이 필요합니다.' + }); + return; + } + + if (!id) { + res.status(400).json({ + success: false, + message: '대시보드 ID가 필요합니다.' + }); + return; + } + + const updateData: UpdateDashboardRequest = req.body; + + // 유효성 검증 + if (updateData.title !== undefined) { + if (typeof updateData.title !== 'string' || updateData.title.trim().length === 0) { + res.status(400).json({ + success: false, + message: '올바른 제목을 입력해주세요.' + }); + return; + } + if (updateData.title.length > 200) { + res.status(400).json({ + success: false, + message: '제목은 200자를 초과할 수 없습니다.' + }); + return; + } + updateData.title = updateData.title.trim(); + } + + if (updateData.description !== undefined && updateData.description && updateData.description.length > 1000) { + res.status(400).json({ + success: false, + message: '설명은 1000자를 초과할 수 없습니다.' + }); + return; + } + + const updatedDashboard = await DashboardService.updateDashboard(id, updateData, userId); + + if (!updatedDashboard) { + res.status(404).json({ + success: false, + message: '대시보드를 찾을 수 없거나 수정 권한이 없습니다.' + }); + return; + } + + res.json({ + success: true, + data: updatedDashboard, + message: '대시보드가 성공적으로 수정되었습니다.' + }); + + } catch (error) { + // console.error('Dashboard update error:', error); + + if ((error as Error).message.includes('권한이 없습니다')) { + res.status(403).json({ + success: false, + message: (error as Error).message + }); + return; + } + + res.status(500).json({ + success: false, + message: '대시보드 수정 중 오류가 발생했습니다.', + error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + }); + } + } + + /** + * 대시보드 삭제 + * DELETE /api/dashboards/:id + */ + async deleteDashboard(req: AuthenticatedRequest, res: Response): Promise { + try { + const { id } = req.params; + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: '인증이 필요합니다.' + }); + return; + } + + if (!id) { + res.status(400).json({ + success: false, + message: '대시보드 ID가 필요합니다.' + }); + return; + } + + const deleted = await DashboardService.deleteDashboard(id, userId); + + if (!deleted) { + res.status(404).json({ + success: false, + message: '대시보드를 찾을 수 없거나 삭제 권한이 없습니다.' + }); + return; + } + + res.json({ + success: true, + message: '대시보드가 성공적으로 삭제되었습니다.' + }); + + } catch (error) { + // console.error('Dashboard delete error:', error); + res.status(500).json({ + success: false, + message: '대시보드 삭제 중 오류가 발생했습니다.', + error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + }); + } + } + + /** + * 내 대시보드 목록 조회 + * GET /api/dashboards/my + */ + async getMyDashboards(req: AuthenticatedRequest, res: Response): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: '인증이 필요합니다.' + }); + return; + } + + const query: DashboardListQuery = { + page: parseInt(req.query.page as string) || 1, + limit: Math.min(parseInt(req.query.limit as string) || 20, 100), + search: req.query.search as string, + category: req.query.category as string, + createdBy: userId // 본인이 만든 대시보드만 + }; + + const result = await DashboardService.getDashboards(query, userId); + + res.json({ + success: true, + data: result.dashboards, + pagination: result.pagination + }); + + } catch (error) { + // console.error('My dashboards error:', error); + res.status(500).json({ + success: false, + message: '내 대시보드 목록 조회 중 오류가 발생했습니다.', + error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + }); + } + } + + /** + * 쿼리 실행 + * POST /api/dashboards/execute-query + */ + async executeQuery(req: AuthenticatedRequest, res: Response): Promise { + try { + // 개발용으로 인증 체크 제거 + // const userId = req.user?.userId; + // if (!userId) { + // res.status(401).json({ + // success: false, + // message: '인증이 필요합니다.' + // }); + // return; + // } + + const { query } = req.body; + + // 유효성 검증 + if (!query || typeof query !== 'string' || query.trim().length === 0) { + res.status(400).json({ + success: false, + message: '쿼리가 필요합니다.' + }); + return; + } + + // SQL 인젝션 방지를 위한 기본적인 검증 + const trimmedQuery = query.trim().toLowerCase(); + if (!trimmedQuery.startsWith('select')) { + res.status(400).json({ + success: false, + message: 'SELECT 쿼리만 허용됩니다.' + }); + return; + } + + // 쿼리 실행 + const result = await PostgreSQLService.query(query.trim()); + + // 결과 변환 + const columns = result.fields?.map(field => field.name) || []; + const rows = result.rows || []; + + res.status(200).json({ + success: true, + data: { + columns, + rows, + rowCount: rows.length + }, + message: '쿼리가 성공적으로 실행되었습니다.' + }); + + } catch (error) { + // console.error('Query execution error:', error); + res.status(500).json({ + success: false, + message: '쿼리 실행 중 오류가 발생했습니다.', + error: process.env.NODE_ENV === 'development' ? (error as Error).message : '쿼리 실행 오류' + }); + } + } +} \ No newline at end of file diff --git a/backend-node/src/database/PostgreSQLService.ts b/backend-node/src/database/PostgreSQLService.ts new file mode 100644 index 00000000..78af7e9d --- /dev/null +++ b/backend-node/src/database/PostgreSQLService.ts @@ -0,0 +1,127 @@ +import { Pool, PoolClient, QueryResult } from 'pg'; +import config from '../config/environment'; + +/** + * PostgreSQL Raw Query 서비스 + * Prisma 대신 직접 pg 라이브러리를 사용 + */ +export class PostgreSQLService { + private static pool: Pool; + + /** + * 데이터베이스 연결 풀 초기화 + */ + static initialize() { + if (!this.pool) { + this.pool = new Pool({ + connectionString: config.databaseUrl, + max: 20, // 최대 연결 수 + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + }); + + // 연결 풀 이벤트 리스너 + this.pool.on('connect', () => { + console.log('🔗 PostgreSQL 연결 성공'); + }); + + this.pool.on('error', (err) => { + console.error('❌ PostgreSQL 연결 오류:', err); + }); + } + } + + /** + * 연결 풀 가져오기 + */ + static getPool(): Pool { + if (!this.pool) { + this.initialize(); + } + return this.pool; + } + + /** + * 단일 쿼리 실행 + */ + static async query(text: string, params?: any[]): Promise { + const pool = this.getPool(); + const start = Date.now(); + + try { + const result = await pool.query(text, params); + const duration = Date.now() - start; + + if (config.debug) { + console.log('🔍 Query executed:', { text, duration: `${duration}ms`, rows: result.rowCount }); + } + + return result; + } catch (error) { + console.error('❌ Query error:', { text, params, error }); + throw error; + } + } + + /** + * 트랜잭션 실행 + */ + static async transaction(callback: (client: PoolClient) => Promise): Promise { + const pool = this.getPool(); + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + const result = await callback(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + /** + * 연결 테스트 + */ + static async testConnection(): Promise { + try { + const result = await this.query('SELECT NOW() as current_time'); + console.log('✅ PostgreSQL 연결 테스트 성공:', result.rows[0]); + return true; + } catch (error) { + console.error('❌ PostgreSQL 연결 테스트 실패:', error); + return false; + } + } + + /** + * 연결 풀 종료 + */ + static async close(): Promise { + if (this.pool) { + await this.pool.end(); + console.log('🔒 PostgreSQL 연결 풀 종료'); + } + } +} + +// 애플리케이션 시작 시 초기화 +PostgreSQLService.initialize(); + +// 프로세스 종료 시 연결 정리 +process.on('SIGINT', async () => { + await PostgreSQLService.close(); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + await PostgreSQLService.close(); + process.exit(0); +}); + +process.on('beforeExit', async () => { + await PostgreSQLService.close(); +}); diff --git a/backend-node/src/routes/dashboardRoutes.ts b/backend-node/src/routes/dashboardRoutes.ts new file mode 100644 index 00000000..e6b5714d --- /dev/null +++ b/backend-node/src/routes/dashboardRoutes.ts @@ -0,0 +1,37 @@ +import { Router } from 'express'; +import { DashboardController } from '../controllers/DashboardController'; +import { authenticateToken } from '../middleware/authMiddleware'; + +const router = Router(); +const dashboardController = new DashboardController(); + +/** + * 대시보드 API 라우트 + * + * 모든 엔드포인트는 인증이 필요하지만, + * 공개 대시보드 조회는 인증 없이도 가능 + */ + +// 공개 대시보드 목록 조회 (인증 불필요) +router.get('/public', dashboardController.getDashboards.bind(dashboardController)); + +// 공개 대시보드 상세 조회 (인증 불필요) +router.get('/public/:id', dashboardController.getDashboard.bind(dashboardController)); + +// 쿼리 실행 (인증 불필요 - 개발용) +router.post('/execute-query', dashboardController.executeQuery.bind(dashboardController)); + +// 인증이 필요한 라우트들 +router.use(authenticateToken); + +// 내 대시보드 목록 조회 +router.get('/my', dashboardController.getMyDashboards.bind(dashboardController)); + +// 대시보드 CRUD +router.post('/', dashboardController.createDashboard.bind(dashboardController)); +router.get('/', dashboardController.getDashboards.bind(dashboardController)); +router.get('/:id', dashboardController.getDashboard.bind(dashboardController)); +router.put('/:id', dashboardController.updateDashboard.bind(dashboardController)); +router.delete('/:id', dashboardController.deleteDashboard.bind(dashboardController)); + +export default router; diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts new file mode 100644 index 00000000..fa0ce775 --- /dev/null +++ b/backend-node/src/services/DashboardService.ts @@ -0,0 +1,534 @@ +import { v4 as uuidv4 } from 'uuid'; +import { PostgreSQLService } from '../database/PostgreSQLService'; +import { + Dashboard, + DashboardElement, + CreateDashboardRequest, + UpdateDashboardRequest, + DashboardListQuery +} from '../types/dashboard'; + +/** + * 대시보드 서비스 - Raw Query 방식 + * PostgreSQL 직접 연결을 통한 CRUD 작업 + */ +export class DashboardService { + + /** + * 대시보드 생성 + */ + static async createDashboard(data: CreateDashboardRequest, userId: string): Promise { + const dashboardId = uuidv4(); + const now = new Date(); + + try { + // 트랜잭션으로 대시보드와 요소들을 함께 생성 + const result = await PostgreSQLService.transaction(async (client) => { + // 1. 대시보드 메인 정보 저장 + await client.query(` + INSERT INTO dashboards ( + id, title, description, is_public, created_by, + created_at, updated_at, tags, category, view_count + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `, [ + dashboardId, + data.title, + data.description || null, + data.isPublic || false, + userId, + now, + now, + JSON.stringify(data.tags || []), + data.category || null, + 0 + ]); + + // 2. 대시보드 요소들 저장 + if (data.elements && data.elements.length > 0) { + for (let i = 0; i < data.elements.length; i++) { + const element = data.elements[i]; + const elementId = uuidv4(); // 항상 새로운 UUID 생성 + + await client.query(` + INSERT INTO dashboard_elements ( + id, dashboard_id, element_type, element_subtype, + position_x, position_y, width, height, + title, content, data_source_config, chart_config, + display_order, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + `, [ + elementId, + dashboardId, + element.type, + element.subtype, + element.position.x, + element.position.y, + element.size.width, + element.size.height, + element.title, + element.content || null, + JSON.stringify(element.dataSource || {}), + JSON.stringify(element.chartConfig || {}), + i, + now, + now + ]); + } + } + + return dashboardId; + }); + + // 생성된 대시보드 반환 + try { + const dashboard = await this.getDashboardById(dashboardId, userId); + if (!dashboard) { + console.error('대시보드 생성은 성공했으나 조회에 실패:', dashboardId); + // 생성은 성공했으므로 기본 정보만이라도 반환 + return { + id: dashboardId, + title: data.title, + description: data.description, + thumbnailUrl: undefined, + isPublic: data.isPublic || false, + createdBy: userId, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + tags: data.tags || [], + category: data.category, + viewCount: 0, + elements: data.elements || [] + }; + } + + return dashboard; + } catch (fetchError) { + console.error('생성된 대시보드 조회 중 오류:', fetchError); + // 생성은 성공했으므로 기본 정보 반환 + return { + id: dashboardId, + title: data.title, + description: data.description, + thumbnailUrl: undefined, + isPublic: data.isPublic || false, + createdBy: userId, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + tags: data.tags || [], + category: data.category, + viewCount: 0, + elements: data.elements || [] + }; + } + + } catch (error) { + console.error('Dashboard creation error:', error); + throw error; + } + } + + /** + * 대시보드 목록 조회 + */ + static async getDashboards(query: DashboardListQuery, userId?: string) { + const { + page = 1, + limit = 20, + search, + category, + isPublic, + createdBy + } = query; + + const offset = (page - 1) * limit; + + try { + // 기본 WHERE 조건 + let whereConditions = ['d.deleted_at IS NULL']; + let params: any[] = []; + let paramIndex = 1; + + // 권한 필터링 + if (userId) { + whereConditions.push(`(d.created_by = $${paramIndex} OR d.is_public = true)`); + params.push(userId); + paramIndex++; + } else { + whereConditions.push('d.is_public = true'); + } + + // 검색 조건 + if (search) { + whereConditions.push(`(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})`); + params.push(`%${search}%`, `%${search}%`); + paramIndex += 2; + } + + // 카테고리 필터 + if (category) { + whereConditions.push(`d.category = $${paramIndex}`); + params.push(category); + paramIndex++; + } + + // 공개/비공개 필터 + if (typeof isPublic === 'boolean') { + whereConditions.push(`d.is_public = $${paramIndex}`); + params.push(isPublic); + paramIndex++; + } + + // 작성자 필터 + if (createdBy) { + whereConditions.push(`d.created_by = $${paramIndex}`); + params.push(createdBy); + paramIndex++; + } + + const whereClause = whereConditions.join(' AND '); + + // 대시보드 목록 조회 (users 테이블 조인 제거) + const dashboardQuery = ` + SELECT + d.id, + d.title, + d.description, + d.thumbnail_url, + d.is_public, + d.created_by, + d.created_at, + d.updated_at, + d.tags, + d.category, + d.view_count, + COUNT(de.id) as elements_count + FROM dashboards d + LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id + WHERE ${whereClause} + GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public, + d.created_by, d.created_at, d.updated_at, d.tags, d.category, + d.view_count + ORDER BY d.updated_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + const dashboardResult = await PostgreSQLService.query( + dashboardQuery, + [...params, limit, offset] + ); + + // 전체 개수 조회 + const countQuery = ` + SELECT COUNT(DISTINCT d.id) as total + FROM dashboards d + WHERE ${whereClause} + `; + + const countResult = await PostgreSQLService.query(countQuery, params); + const total = parseInt(countResult.rows[0]?.total || '0'); + + return { + dashboards: dashboardResult.rows.map((row: any) => ({ + id: row.id, + title: row.title, + description: row.description, + thumbnailUrl: row.thumbnail_url, + isPublic: row.is_public, + createdBy: row.created_by, + createdAt: row.created_at, + updatedAt: row.updated_at, + tags: JSON.parse(row.tags || '[]'), + category: row.category, + viewCount: parseInt(row.view_count || '0'), + elementsCount: parseInt(row.elements_count || '0') + })), + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit) + } + }; + } catch (error) { + console.error('Dashboard list error:', error); + throw error; + } + } + + /** + * 대시보드 상세 조회 + */ + static async getDashboardById(dashboardId: string, userId?: string): Promise { + try { + // 1. 대시보드 기본 정보 조회 (권한 체크 포함) + let dashboardQuery: string; + let dashboardParams: any[]; + + if (userId) { + dashboardQuery = ` + SELECT d.* + FROM dashboards d + WHERE d.id = $1 AND d.deleted_at IS NULL + AND (d.created_by = $2 OR d.is_public = true) + `; + dashboardParams = [dashboardId, userId]; + } else { + dashboardQuery = ` + SELECT d.* + FROM dashboards d + WHERE d.id = $1 AND d.deleted_at IS NULL + AND d.is_public = true + `; + dashboardParams = [dashboardId]; + } + + const dashboardResult = await PostgreSQLService.query(dashboardQuery, dashboardParams); + + if (dashboardResult.rows.length === 0) { + return null; + } + + const dashboard = dashboardResult.rows[0]; + + // 2. 대시보드 요소들 조회 + const elementsQuery = ` + SELECT * FROM dashboard_elements + WHERE dashboard_id = $1 + ORDER BY display_order ASC + `; + + const elementsResult = await PostgreSQLService.query(elementsQuery, [dashboardId]); + + // 3. 요소 데이터 변환 + const elements: DashboardElement[] = elementsResult.rows.map((row: any) => ({ + id: row.id, + type: row.element_type, + subtype: row.element_subtype, + position: { + x: row.position_x, + y: row.position_y + }, + size: { + width: row.width, + height: row.height + }, + title: row.title, + content: row.content, + dataSource: JSON.parse(row.data_source_config || '{}'), + chartConfig: JSON.parse(row.chart_config || '{}') + })); + + return { + id: dashboard.id, + title: dashboard.title, + description: dashboard.description, + thumbnailUrl: dashboard.thumbnail_url, + isPublic: dashboard.is_public, + createdBy: dashboard.created_by, + createdAt: dashboard.created_at, + updatedAt: dashboard.updated_at, + tags: JSON.parse(dashboard.tags || '[]'), + category: dashboard.category, + viewCount: parseInt(dashboard.view_count || '0'), + elements + }; + } catch (error) { + console.error('Dashboard get error:', error); + throw error; + } + } + + /** + * 대시보드 업데이트 + */ + static async updateDashboard( + dashboardId: string, + data: UpdateDashboardRequest, + userId: string + ): Promise { + try { + const result = await PostgreSQLService.transaction(async (client) => { + // 권한 체크 + const authCheckResult = await client.query(` + SELECT id FROM dashboards + WHERE id = $1 AND created_by = $2 AND deleted_at IS NULL + `, [dashboardId, userId]); + + if (authCheckResult.rows.length === 0) { + throw new Error('대시보드를 찾을 수 없거나 수정 권한이 없습니다.'); + } + + const now = new Date(); + + // 1. 대시보드 메인 정보 업데이트 + const updateFields: string[] = []; + const updateParams: any[] = []; + let paramIndex = 1; + + if (data.title !== undefined) { + updateFields.push(`title = $${paramIndex}`); + updateParams.push(data.title); + paramIndex++; + } + if (data.description !== undefined) { + updateFields.push(`description = $${paramIndex}`); + updateParams.push(data.description); + paramIndex++; + } + if (data.isPublic !== undefined) { + updateFields.push(`is_public = $${paramIndex}`); + updateParams.push(data.isPublic); + paramIndex++; + } + if (data.tags !== undefined) { + updateFields.push(`tags = $${paramIndex}`); + updateParams.push(JSON.stringify(data.tags)); + paramIndex++; + } + if (data.category !== undefined) { + updateFields.push(`category = $${paramIndex}`); + updateParams.push(data.category); + paramIndex++; + } + + updateFields.push(`updated_at = $${paramIndex}`); + updateParams.push(now); + paramIndex++; + + updateParams.push(dashboardId); + + if (updateFields.length > 1) { // updated_at 외에 다른 필드가 있는 경우 + const updateQuery = ` + UPDATE dashboards + SET ${updateFields.join(', ')} + WHERE id = $${paramIndex} + `; + + await client.query(updateQuery, updateParams); + } + + // 2. 요소 업데이트 (있는 경우) + if (data.elements) { + // 기존 요소들 삭제 + await client.query(` + DELETE FROM dashboard_elements WHERE dashboard_id = $1 + `, [dashboardId]); + + // 새 요소들 추가 + for (let i = 0; i < data.elements.length; i++) { + const element = data.elements[i]; + const elementId = uuidv4(); + + await client.query(` + INSERT INTO dashboard_elements ( + id, dashboard_id, element_type, element_subtype, + position_x, position_y, width, height, + title, content, data_source_config, chart_config, + display_order, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + `, [ + elementId, + dashboardId, + element.type, + element.subtype, + element.position.x, + element.position.y, + element.size.width, + element.size.height, + element.title, + element.content || null, + JSON.stringify(element.dataSource || {}), + JSON.stringify(element.chartConfig || {}), + i, + now, + now + ]); + } + } + + return dashboardId; + }); + + // 업데이트된 대시보드 반환 + return await this.getDashboardById(dashboardId, userId); + + } catch (error) { + console.error('Dashboard update error:', error); + throw error; + } + } + + /** + * 대시보드 삭제 (소프트 삭제) + */ + static async deleteDashboard(dashboardId: string, userId: string): Promise { + try { + const now = new Date(); + + const result = await PostgreSQLService.query(` + UPDATE dashboards + SET deleted_at = $1, updated_at = $2 + WHERE id = $3 AND created_by = $4 AND deleted_at IS NULL + `, [now, now, dashboardId, userId]); + + return (result.rowCount || 0) > 0; + } catch (error) { + console.error('Dashboard delete error:', error); + throw error; + } + } + + /** + * 조회수 증가 + */ + static async incrementViewCount(dashboardId: string): Promise { + try { + await PostgreSQLService.query(` + UPDATE dashboards + SET view_count = view_count + 1 + WHERE id = $1 AND deleted_at IS NULL + `, [dashboardId]); + } catch (error) { + console.error('View count increment error:', error); + // 조회수 증가 실패는 치명적이지 않으므로 에러를 던지지 않음 + } + } + + /** + * 사용자 권한 체크 + */ + static async checkUserPermission( + dashboardId: string, + userId: string, + requiredPermission: 'view' | 'edit' | 'admin' = 'view' + ): Promise { + try { + const result = await PostgreSQLService.query(` + SELECT + CASE + WHEN d.created_by = $2 THEN 'admin' + WHEN d.is_public = true THEN 'view' + ELSE 'none' + END as permission + FROM dashboards d + WHERE d.id = $1 AND d.deleted_at IS NULL + `, [dashboardId, userId]); + + if (result.rows.length === 0) { + return false; + } + + const userPermission = result.rows[0].permission; + + // 권한 레벨 체크 + const permissionLevels = { 'view': 1, 'edit': 2, 'admin': 3 }; + const userLevel = permissionLevels[userPermission as keyof typeof permissionLevels] || 0; + const requiredLevel = permissionLevels[requiredPermission]; + + return userLevel >= requiredLevel; + } catch (error) { + console.error('Permission check error:', error); + return false; + } + } +} \ No newline at end of file diff --git a/backend-node/src/types/dashboard.ts b/backend-node/src/types/dashboard.ts new file mode 100644 index 00000000..c37beae8 --- /dev/null +++ b/backend-node/src/types/dashboard.ts @@ -0,0 +1,90 @@ +/** + * 대시보드 관련 타입 정의 + */ + +export interface DashboardElement { + id: string; + type: 'chart' | 'widget'; + subtype: 'bar' | 'pie' | 'line' | 'exchange' | 'weather'; + position: { + x: number; + y: number; + }; + size: { + width: number; + height: number; + }; + title: string; + content?: string; + dataSource?: { + type: 'api' | 'database' | 'static'; + endpoint?: string; + query?: string; + refreshInterval?: number; + filters?: any[]; + lastExecuted?: string; + }; + chartConfig?: { + xAxis?: string; + yAxis?: string; + groupBy?: string; + aggregation?: 'sum' | 'avg' | 'count' | 'max' | 'min'; + colors?: string[]; + title?: string; + showLegend?: boolean; + }; +} + +export interface Dashboard { + id: string; + title: string; + description?: string; + thumbnailUrl?: string; + isPublic: boolean; + createdBy: string; + createdAt: string; + updatedAt: string; + deletedAt?: string; + tags?: string[]; + category?: string; + viewCount: number; + elements: DashboardElement[]; +} + +export interface CreateDashboardRequest { + title: string; + description?: string; + isPublic?: boolean; + elements: DashboardElement[]; + tags?: string[]; + category?: string; +} + +export interface UpdateDashboardRequest { + title?: string; + description?: string; + isPublic?: boolean; + elements?: DashboardElement[]; + tags?: string[]; + category?: string; +} + +export interface DashboardListQuery { + page?: number; + limit?: number; + search?: string; + category?: string; + isPublic?: boolean; + createdBy?: string; +} + +export interface DashboardShare { + id: string; + dashboardId: string; + sharedWithUser?: string; + sharedWithRole?: string; + permissionLevel: 'view' | 'edit' | 'admin'; + createdBy: string; + createdAt: string; + expiresAt?: string; +} diff --git a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx new file mode 100644 index 00000000..f01e31ad --- /dev/null +++ b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx @@ -0,0 +1,286 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { DashboardViewer } from '@/components/dashboard/DashboardViewer'; +import { DashboardElement } from '@/components/admin/dashboard/types'; + +interface DashboardViewPageProps { + params: { + dashboardId: string; + }; +} + +/** + * 대시보드 뷰어 페이지 + * - 저장된 대시보드를 읽기 전용으로 표시 + * - 실시간 데이터 업데이트 + * - 전체화면 모드 지원 + */ +export default function DashboardViewPage({ params }: DashboardViewPageProps) { + const [dashboard, setDashboard] = useState<{ + id: string; + title: string; + description?: string; + elements: DashboardElement[]; + createdAt: string; + updatedAt: string; + } | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // 대시보드 데이터 로딩 + useEffect(() => { + loadDashboard(); + }, [params.dashboardId]); + + const loadDashboard = async () => { + setIsLoading(true); + setError(null); + + try { + // 실제 API 호출 시도 + const { dashboardApi } = await import('@/lib/api/dashboard'); + + try { + const dashboardData = await dashboardApi.getDashboard(params.dashboardId); + setDashboard(dashboardData); + } catch (apiError) { + console.warn('API 호출 실패, 로컬 스토리지 확인:', apiError); + + // API 실패 시 로컬 스토리지에서 찾기 + const savedDashboards = JSON.parse(localStorage.getItem('savedDashboards') || '[]'); + const savedDashboard = savedDashboards.find((d: any) => d.id === params.dashboardId); + + if (savedDashboard) { + setDashboard(savedDashboard); + } else { + // 로컬에도 없으면 샘플 데이터 사용 + const sampleDashboard = generateSampleDashboard(params.dashboardId); + setDashboard(sampleDashboard); + } + } + } catch (err) { + setError('대시보드를 불러오는 중 오류가 발생했습니다.'); + console.error('Dashboard loading error:', err); + } finally { + setIsLoading(false); + } + }; + + // 로딩 상태 + if (isLoading) { + return ( +
+
+
+
대시보드 로딩 중...
+
잠시만 기다려주세요
+
+
+ ); + } + + // 에러 상태 + if (error || !dashboard) { + return ( +
+
+
😞
+
+ {error || '대시보드를 찾을 수 없습니다'} +
+
+ 대시보드 ID: {params.dashboardId} +
+ +
+
+ ); + } + + return ( +
+ {/* 대시보드 헤더 */} +
+
+
+

{dashboard.title}

+ {dashboard.description && ( +

{dashboard.description}

+ )} +
+ +
+ {/* 새로고침 버튼 */} + + + {/* 전체화면 버튼 */} + + + {/* 편집 버튼 */} + +
+
+ + {/* 메타 정보 */} +
+ 생성: {new Date(dashboard.createdAt).toLocaleString()} + 수정: {new Date(dashboard.updatedAt).toLocaleString()} + 요소: {dashboard.elements.length}개 +
+
+ + {/* 대시보드 뷰어 */} +
+ +
+
+ ); +} + +/** + * 샘플 대시보드 생성 함수 + */ +function generateSampleDashboard(dashboardId: string) { + const dashboards: Record = { + 'sales-overview': { + id: 'sales-overview', + title: '📊 매출 현황 대시보드', + description: '월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.', + elements: [ + { + id: 'chart-1', + type: 'chart', + subtype: 'bar', + position: { x: 20, y: 20 }, + size: { width: 400, height: 300 }, + title: '📊 월별 매출 추이', + content: '월별 매출 데이터', + dataSource: { + type: 'database', + query: 'SELECT month, sales FROM monthly_sales', + refreshInterval: 30000 + }, + chartConfig: { + xAxis: 'month', + yAxis: 'sales', + title: '월별 매출 추이', + colors: ['#3B82F6', '#EF4444', '#10B981'] + } + }, + { + id: 'chart-2', + type: 'chart', + subtype: 'pie', + position: { x: 450, y: 20 }, + size: { width: 350, height: 300 }, + title: '🥧 상품별 판매 비율', + content: '상품별 판매 데이터', + dataSource: { + type: 'database', + query: 'SELECT product_name, total_sold FROM product_sales', + refreshInterval: 60000 + }, + chartConfig: { + xAxis: 'product_name', + yAxis: 'total_sold', + title: '상품별 판매 비율', + colors: ['#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'] + } + }, + { + id: 'chart-3', + type: 'chart', + subtype: 'line', + position: { x: 20, y: 350 }, + size: { width: 780, height: 250 }, + title: '📈 사용자 가입 추이', + content: '사용자 가입 데이터', + dataSource: { + type: 'database', + query: 'SELECT week, new_users FROM user_growth', + refreshInterval: 300000 + }, + chartConfig: { + xAxis: 'week', + yAxis: 'new_users', + title: '주간 신규 사용자 가입 추이', + colors: ['#10B981'] + } + } + ], + createdAt: '2024-09-30T10:00:00Z', + updatedAt: '2024-09-30T14:30:00Z' + }, + 'user-analytics': { + id: 'user-analytics', + title: '👥 사용자 분석 대시보드', + description: '사용자 행동 패턴 및 가입 추이 분석', + elements: [ + { + id: 'chart-4', + type: 'chart', + subtype: 'line', + position: { x: 20, y: 20 }, + size: { width: 500, height: 300 }, + title: '📈 일일 활성 사용자', + content: '사용자 활동 데이터', + dataSource: { + type: 'database', + query: 'SELECT date, active_users FROM daily_active_users', + refreshInterval: 60000 + }, + chartConfig: { + xAxis: 'date', + yAxis: 'active_users', + title: '일일 활성 사용자 추이' + } + } + ], + createdAt: '2024-09-29T15:00:00Z', + updatedAt: '2024-09-30T09:15:00Z' + } + }; + + return dashboards[dashboardId] || { + id: dashboardId, + title: `대시보드 ${dashboardId}`, + description: '샘플 대시보드입니다.', + elements: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; +} diff --git a/frontend/app/(main)/dashboard/page.tsx b/frontend/app/(main)/dashboard/page.tsx index 6a5847fd..50245fb8 100644 --- a/frontend/app/(main)/dashboard/page.tsx +++ b/frontend/app/(main)/dashboard/page.tsx @@ -1,270 +1,287 @@ -"use client"; +'use client'; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { - Home, - FileText, - Users, - Settings, - Package, - BarChart3, - LogOut, - Menu, - X, - ChevronDown, - ChevronRight, -} from "lucide-react"; -import { useAuth } from "@/hooks/useAuth"; +import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; -interface UserInfo { - userId: string; - userName: string; - deptName: string; - email: string; -} - -interface MenuItem { +interface Dashboard { id: string; title: string; - icon: any; - children?: MenuItem[]; + description?: string; + thumbnail?: string; + elementsCount: number; + createdAt: string; + updatedAt: string; + isPublic: boolean; } -const menuItems: MenuItem[] = [ - { - id: "dashboard", - title: "대시보드", - icon: Home, - }, - { - id: "project", - title: "프로젝트 관리", - icon: FileText, - children: [ - { id: "project-list", title: "프로젝트 목록", icon: FileText }, - { id: "project-concept", title: "프로젝트 컨셉", icon: FileText }, - { id: "project-planning", title: "프로젝트 기획", icon: FileText }, - ], - }, - { - id: "part", - title: "부품 관리", - icon: Package, - children: [ - { id: "part-list", title: "부품 목록", icon: Package }, - { id: "part-bom", title: "BOM 관리", icon: Package }, - { id: "part-inventory", title: "재고 관리", icon: Package }, - ], - }, - { - id: "user", - title: "사용자 관리", - icon: Users, - children: [ - { id: "user-list", title: "사용자 목록", icon: Users }, - { id: "user-auth", title: "권한 관리", icon: Users }, - ], - }, - { - id: "report", - title: "보고서", - icon: BarChart3, - children: [ - { id: "report-project", title: "프로젝트 보고서", icon: BarChart3 }, - { id: "report-cost", title: "비용 보고서", icon: BarChart3 }, - ], - }, - { - id: "settings", - title: "시스템 설정", - icon: Settings, - children: [ - { id: "settings-system", title: "시스템 설정", icon: Settings }, - { id: "settings-common", title: "공통 코드", icon: Settings }, - ], - }, -]; +/** + * 대시보드 목록 페이지 + * - 저장된 대시보드들의 목록 표시 + * - 새 대시보드 생성 링크 + * - 대시보드 미리보기 및 관리 + */ +export default function DashboardListPage() { + const [dashboards, setDashboards] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); -export default function DashboardPage() { - const { user, logout } = useAuth(); - const [sidebarOpen, setSidebarOpen] = useState(true); - const [expandedMenus, setExpandedMenus] = useState>(new Set(["dashboard"])); - const [selectedMenu, setSelectedMenu] = useState("dashboard"); - const [currentContent, setCurrentContent] = useState("dashboard"); + // 대시보드 목록 로딩 + useEffect(() => { + loadDashboards(); + }, []); - const handleLogout = async () => { - await logout(); - }; - - const toggleMenu = (menuId: string) => { - const newExpanded = new Set(expandedMenus); - if (newExpanded.has(menuId)) { - newExpanded.delete(menuId); - } else { - newExpanded.add(menuId); - } - setExpandedMenus(newExpanded); - }; - - const handleMenuClick = (menuId: string) => { - setSelectedMenu(menuId); - setCurrentContent(menuId); - }; - - const renderContent = () => { - switch (currentContent) { - case "dashboard": - return ( -
-

대시보드

-
- - -
- -
-

전체 프로젝트

-

24

-
-
-
-
- - -
- -
-

등록된 부품

-

1,247

-
-
-
-
- - -
- -
-

활성 사용자

-

89

-
-
-
-
- - -
- -
-

진행중인 작업

-

12

-
-
-
-
-
- - -

최근 활동

-
-
-
- 새로운 프로젝트 '제품 A' 생성됨 - 2시간 전 -
-
-
- 부품 'PCB-001' 승인 완료 - 4시간 전 -
-
-
- 사용자 '김개발' 권한 변경 - 1일 전 -
-
-
-
-
- ); - default: - return ( -
-

- {menuItems.find((item) => item.id === currentContent)?.title || - menuItems.flatMap((item) => item.children || []).find((child) => child.id === currentContent)?.title || - "페이지를 찾을 수 없습니다"} -

- - -

{currentContent} 페이지 컨텐츠가 여기에 표시됩니다.

-

각 메뉴에 맞는 컴포넌트를 개발하여 연결할 수 있습니다.

-
-
-
- ); + const loadDashboards = async () => { + setIsLoading(true); + + try { + // 실제 API 호출 시도 + const { dashboardApi } = await import('@/lib/api/dashboard'); + + try { + const result = await dashboardApi.getDashboards({ page: 1, limit: 50 }); + + // API에서 가져온 대시보드들을 Dashboard 형식으로 변환 + const apiDashboards: Dashboard[] = result.dashboards.map((dashboard: any) => ({ + id: dashboard.id, + title: dashboard.title, + description: dashboard.description, + elementsCount: dashboard.elementsCount || dashboard.elements?.length || 0, + createdAt: dashboard.createdAt, + updatedAt: dashboard.updatedAt, + isPublic: dashboard.isPublic, + creatorName: dashboard.creatorName + })); + + setDashboards(apiDashboards); + + } catch (apiError) { + console.warn('API 호출 실패, 로컬 스토리지 및 샘플 데이터 사용:', apiError); + + // API 실패 시 로컬 스토리지 + 샘플 데이터 사용 + const savedDashboards = JSON.parse(localStorage.getItem('savedDashboards') || '[]'); + + // 샘플 대시보드들 + const sampleDashboards: Dashboard[] = [ + { + id: 'sales-overview', + title: '📊 매출 현황 대시보드', + description: '월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.', + elementsCount: 3, + createdAt: '2024-09-30T10:00:00Z', + updatedAt: '2024-09-30T14:30:00Z', + isPublic: true + }, + { + id: 'user-analytics', + title: '👥 사용자 분석 대시보드', + description: '사용자 행동 패턴 및 가입 추이 분석', + elementsCount: 1, + createdAt: '2024-09-29T15:00:00Z', + updatedAt: '2024-09-30T09:15:00Z', + isPublic: false + }, + { + id: 'inventory-status', + title: '📦 재고 현황 대시보드', + description: '실시간 재고 현황 및 입출고 내역', + elementsCount: 4, + createdAt: '2024-09-28T11:30:00Z', + updatedAt: '2024-09-29T16:45:00Z', + isPublic: true + } + ]; + + // 저장된 대시보드를 Dashboard 형식으로 변환 + const userDashboards: Dashboard[] = savedDashboards.map((dashboard: any) => ({ + id: dashboard.id, + title: dashboard.title, + description: dashboard.description, + elementsCount: dashboard.elements?.length || 0, + createdAt: dashboard.createdAt, + updatedAt: dashboard.updatedAt, + isPublic: false // 사용자가 만든 대시보드는 기본적으로 비공개 + })); + + // 사용자 대시보드를 맨 앞에 배치 + setDashboards([...userDashboards, ...sampleDashboards]); + } + } catch (error) { + console.error('Dashboard loading error:', error); + } finally { + setIsLoading(false); } }; - const renderMenuItem = (item: MenuItem, level: number = 0) => { - const isExpanded = expandedMenus.has(item.id); - const isSelected = selectedMenu === item.id; - const hasChildren = item.children && item.children.length > 0; - - return ( -
-
0 ? "ml-6" : ""}`} - onClick={() => { - if (hasChildren) { - toggleMenu(item.id); - } else { - handleMenuClick(item.id); - } - }} - > - - {item.title} - {hasChildren && (isExpanded ? : )} -
- {hasChildren && isExpanded && ( -
{item.children?.map((child) => renderMenuItem(child, level + 1))}
- )} -
- ); - }; + // 검색 필터링 + const filteredDashboards = dashboards.filter(dashboard => + dashboard.title.toLowerCase().includes(searchTerm.toLowerCase()) || + dashboard.description?.toLowerCase().includes(searchTerm.toLowerCase()) + ); return ( -
+
{/* 헤더 */} -
-
-
-
- -

PLM 시스템

+
+
+
+
+

📊 대시보드

+

데이터를 시각화하고 인사이트를 얻어보세요

+
+ + + ➕ 새 대시보드 만들기 + +
+ + {/* 검색 바 */} +
+
+ setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ 🔍 +
-
+
-
- {/* 사이드바 */} - - - {/* 메인 컨텐츠 */} -
{renderContent()}
+ {/* 메인 콘텐츠 */} +
+ {isLoading ? ( + // 로딩 상태 +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) : filteredDashboards.length === 0 ? ( + // 빈 상태 +
+
📊
+

+ {searchTerm ? '검색 결과가 없습니다' : '아직 대시보드가 없습니다'} +

+

+ {searchTerm + ? '다른 검색어로 시도해보세요' + : '첫 번째 대시보드를 만들어보세요'} +

+ {!searchTerm && ( + + ➕ 대시보드 만들기 + + )} +
+ ) : ( + // 대시보드 그리드 +
+ {filteredDashboards.map((dashboard) => ( + + ))} +
+ )}
); } + +interface DashboardCardProps { + dashboard: Dashboard; +} + +/** + * 개별 대시보드 카드 컴포넌트 + */ +function DashboardCard({ dashboard }: DashboardCardProps) { + return ( +
+ {/* 썸네일 영역 */} +
+
+
📊
+
{dashboard.elementsCount}개 요소
+
+
+ + {/* 카드 내용 */} +
+
+

+ {dashboard.title} +

+ {dashboard.isPublic ? ( + + 공개 + + ) : ( + + 비공개 + + )} +
+ + {dashboard.description && ( +

+ {dashboard.description} +

+ )} + + {/* 메타 정보 */} +
+
생성: {new Date(dashboard.createdAt).toLocaleDateString()}
+
수정: {new Date(dashboard.updatedAt).toLocaleDateString()}
+
+ + {/* 액션 버튼들 */} +
+ + 보기 + + + 편집 + + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index fc4e7d0b..d7074aec 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -141,11 +141,22 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec setIsLoadingData(true); try { - // 실제 API 호출 대신 샘플 데이터 생성 - const sampleData = generateSampleData(element.dataSource.query, element.subtype); - setChartData(sampleData); + // console.log('🔄 쿼리 실행 시작:', element.dataSource.query); + + // 실제 API 호출 + const { dashboardApi } = await import('@/lib/api/dashboard'); + const result = await dashboardApi.executeQuery(element.dataSource.query); + + // console.log('✅ 쿼리 실행 결과:', result); + + setChartData({ + columns: result.columns || [], + rows: result.rows || [], + totalRows: result.rowCount || 0, + executionTime: 0 + }); } catch (error) { - console.error('데이터 로딩 오류:', error); + // console.error('❌ 데이터 로딩 오류:', error); setChartData(null); } finally { setIsLoadingData(false); diff --git a/frontend/components/admin/dashboard/ChartConfigPanel.tsx b/frontend/components/admin/dashboard/ChartConfigPanel.tsx index 11f3865d..5ba617a3 100644 --- a/frontend/components/admin/dashboard/ChartConfigPanel.tsx +++ b/frontend/components/admin/dashboard/ChartConfigPanel.tsx @@ -77,40 +77,85 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
- {/* Y축 설정 */} + {/* Y축 설정 (다중 선택 가능) */}
- +
+ {availableColumns.map((col) => { + const isSelected = Array.isArray(currentConfig.yAxis) + ? currentConfig.yAxis.includes(col) + : currentConfig.yAxis === col; + + return ( + + ); + })} +
+
+ 💡 팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰) +
{/* 집계 함수 */}
- + +
+ 💡 집계 함수는 현재 쿼리 결과에 적용되지 않습니다. + SQL 쿼리에서 직접 집계하는 것을 권장합니다. +
{/* 그룹핑 필드 (선택사항) */} @@ -182,12 +227,23 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
📋 설정 미리보기
X축: {currentConfig.xAxis || '미설정'}
-
Y축: {currentConfig.yAxis || '미설정'}
+
+ Y축:{' '} + {Array.isArray(currentConfig.yAxis) + ? `${currentConfig.yAxis.length}개 (${currentConfig.yAxis.join(', ')})` + : currentConfig.yAxis || '미설정' + } +
집계: {currentConfig.aggregation || 'sum'}
{currentConfig.groupBy && (
그룹핑: {currentConfig.groupBy}
)}
데이터 행 수: {queryResult.rows.length}개
+ {Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 1 && ( +
+ ✨ 다중 시리즈 차트가 생성됩니다! +
+ )}
diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index d86765a3..ed0b253f 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -57,7 +57,7 @@ export const DashboardCanvas = forwardRef( onCreateElement(dragData.type, dragData.subtype, x, y); } catch (error) { - console.error('드롭 데이터 파싱 오류:', error); + // console.error('드롭 데이터 파싱 오류:', error); } }, [ref, onCreateElement]); diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 05f0989b..e13209f1 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -18,8 +18,60 @@ export default function DashboardDesigner() { const [selectedElement, setSelectedElement] = useState(null); const [elementCounter, setElementCounter] = useState(0); const [configModalElement, setConfigModalElement] = useState(null); + const [dashboardId, setDashboardId] = useState(null); + const [dashboardTitle, setDashboardTitle] = useState(''); + const [isLoading, setIsLoading] = useState(false); const canvasRef = useRef(null); + // URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드 + React.useEffect(() => { + const params = new URLSearchParams(window.location.search); + const loadId = params.get('load'); + + if (loadId) { + loadDashboard(loadId); + } + }, []); + + // 대시보드 데이터 로드 + const loadDashboard = async (id: string) => { + setIsLoading(true); + try { + // console.log('🔄 대시보드 로딩:', id); + + const { dashboardApi } = await import('@/lib/api/dashboard'); + const dashboard = await dashboardApi.getDashboard(id); + + // console.log('✅ 대시보드 로딩 완료:', dashboard); + + // 대시보드 정보 설정 + setDashboardId(dashboard.id); + setDashboardTitle(dashboard.title); + + // 요소들 설정 + if (dashboard.elements && dashboard.elements.length > 0) { + setElements(dashboard.elements); + + // elementCounter를 가장 큰 ID 번호로 설정 + const maxId = dashboard.elements.reduce((max, el) => { + const match = el.id.match(/element-(\d+)/); + if (match) { + const num = parseInt(match[1]); + return num > max ? num : max; + } + return max; + }, 0); + setElementCounter(maxId); + } + + } catch (error) { + // console.error('❌ 대시보드 로딩 오류:', error); + alert('대시보드를 불러오는 중 오류가 발생했습니다.\n\n' + (error instanceof Error ? error.message : '알 수 없는 오류')); + } finally { + setIsLoading(false); + } + }; + // 새로운 요소 생성 const createElement = useCallback(( type: ElementType, @@ -82,28 +134,98 @@ export default function DashboardDesigner() { }, [updateElement]); // 레이아웃 저장 - const saveLayout = useCallback(() => { - const layoutData = { - elements: elements.map(el => ({ + const saveLayout = useCallback(async () => { + if (elements.length === 0) { + alert('저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.'); + return; + } + + try { + // 실제 API 호출 + const { dashboardApi } = await import('@/lib/api/dashboard'); + + const elementsData = elements.map(el => ({ + id: el.id, type: el.type, subtype: el.subtype, position: el.position, size: el.size, title: el.title, + content: el.content, dataSource: el.dataSource, chartConfig: el.chartConfig - })), - timestamp: new Date().toISOString() - }; - - console.log('저장된 레이아웃:', JSON.stringify(layoutData, null, 2)); - alert('레이아웃이 콘솔에 저장되었습니다. (F12를 눌러 확인하세요)'); - }, [elements]); + })); + + let savedDashboard; + + if (dashboardId) { + // 기존 대시보드 업데이트 + // console.log('🔄 대시보드 업데이트:', dashboardId); + savedDashboard = await dashboardApi.updateDashboard(dashboardId, { + elements: elementsData + }); + + alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`); + + // 뷰어 페이지로 이동 + window.location.href = `/dashboard/${savedDashboard.id}`; + + } else { + // 새 대시보드 생성 + const title = prompt('대시보드 제목을 입력하세요:', '새 대시보드'); + if (!title) return; + + const description = prompt('대시보드 설명을 입력하세요 (선택사항):', ''); + + const dashboardData = { + title, + description: description || undefined, + isPublic: false, + elements: elementsData + }; + + savedDashboard = await dashboardApi.createDashboard(dashboardData); + + // console.log('✅ 대시보드 생성 완료:', savedDashboard); + + const viewDashboard = confirm(`대시보드 "${title}"이 저장되었습니다!\n\n지금 확인해보시겠습니까?`); + if (viewDashboard) { + window.location.href = `/dashboard/${savedDashboard.id}`; + } + } + + } catch (error) { + // console.error('❌ 저장 오류:', error); + + const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류'; + alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`); + } + }, [elements, dashboardId]); + + // 로딩 중이면 로딩 화면 표시 + if (isLoading) { + return ( +
+
+
+
대시보드 로딩 중...
+
잠시만 기다려주세요
+
+
+ ); + } return (
{/* 캔버스 영역 */}
+ {/* 편집 중인 대시보드 표시 */} + {dashboardTitle && ( +
+ 📝 편집 중: {dashboardTitle} +
+ )} + + + + +
diff --git a/frontend/components/admin/dashboard/QueryEditor.tsx b/frontend/components/admin/dashboard/QueryEditor.tsx index d0ac90c2..825330d4 100644 --- a/frontend/components/admin/dashboard/QueryEditor.tsx +++ b/frontend/components/admin/dashboard/QueryEditor.tsx @@ -32,10 +32,35 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que setError(null); try { - // 실제 API 호출 대신 샘플 데이터 생성 - await new Promise(resolve => setTimeout(resolve, 500 + Math.random() * 1000)); // 실제 API 호출 시뮬레이션 + // 실제 API 호출 + const response = await fetch('http://localhost:8080/api/dashboards/execute-query', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token') || 'test-token'}` // JWT 토큰 사용 + }, + body: JSON.stringify({ query: query.trim() }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || '쿼리 실행에 실패했습니다.'); + } + + const apiResult = await response.json(); + + if (!apiResult.success) { + throw new Error(apiResult.message || '쿼리 실행에 실패했습니다.'); + } + + // API 결과를 QueryResult 형식으로 변환 + const result: QueryResult = { + columns: apiResult.data.columns, + rows: apiResult.data.rows, + totalRows: apiResult.data.rowCount, + executionTime: 0 // API에서 실행 시간을 제공하지 않으므로 0으로 설정 + }; - const result: QueryResult = generateSampleQueryResult(query.trim()); setQueryResult(result); onQueryTest?.(result); @@ -50,7 +75,7 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que } catch (err) { const errorMessage = err instanceof Error ? err.message : '쿼리 실행 중 오류가 발생했습니다.'; setError(errorMessage); - console.error('Query execution error:', err); + // console.error('Query execution error:', err); } finally { setIsExecuting(false); } @@ -59,6 +84,18 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que // 샘플 쿼리 삽입 const insertSampleQuery = useCallback((sampleType: string) => { const samples = { + comparison: `-- 제품별 월별 매출 비교 (다중 시리즈) +-- 갤럭시(Galaxy) vs 아이폰(iPhone) 매출 비교 +SELECT + DATE_TRUNC('month', order_date) as month, + SUM(CASE WHEN product_category = '갤럭시' THEN amount ELSE 0 END) as galaxy_sales, + SUM(CASE WHEN product_category = '아이폰' THEN amount ELSE 0 END) as iphone_sales, + SUM(CASE WHEN product_category = '기타' THEN amount ELSE 0 END) as other_sales +FROM orders +WHERE order_date >= CURRENT_DATE - INTERVAL '12 months' +GROUP BY DATE_TRUNC('month', order_date) +ORDER BY month;`, + sales: `-- 월별 매출 데이터 SELECT DATE_TRUNC('month', order_date) as month, @@ -88,7 +125,19 @@ JOIN products p ON oi.product_id = p.id WHERE oi.created_at >= CURRENT_DATE - INTERVAL '1 month' GROUP BY product_name ORDER BY total_sold DESC -LIMIT 10;` +LIMIT 10;`, + + regional: `-- 지역별 매출 비교 +SELECT + region as 지역, + SUM(CASE WHEN quarter = 'Q1' THEN sales ELSE 0 END) as Q1, + SUM(CASE WHEN quarter = 'Q2' THEN sales ELSE 0 END) as Q2, + SUM(CASE WHEN quarter = 'Q3' THEN sales ELSE 0 END) as Q3, + SUM(CASE WHEN quarter = 'Q4' THEN sales ELSE 0 END) as Q4 +FROM regional_sales +WHERE year = EXTRACT(YEAR FROM CURRENT_DATE) +GROUP BY region +ORDER BY Q4 DESC;` }; setQuery(samples[sampleType as keyof typeof samples] || ''); @@ -124,6 +173,18 @@ LIMIT 10;` {/* 샘플 쿼리 버튼들 */}
샘플 쿼리: + + + )} +
+ + {/* 내용 */} +
+ {element.type === 'chart' ? ( + + ) : ( + // 위젯 렌더링 +
+
+
+ {element.subtype === 'exchange' && '💱'} + {element.subtype === 'weather' && '☁️'} +
+
{element.content}
+
+
+ )} +
+ + {/* 로딩 오버레이 */} + {isLoading && ( +
+
+
+
업데이트 중...
+
+
+ )} +
+ ); +} + +/** + * 샘플 쿼리 결과 생성 함수 (뷰어용) + */ +function generateSampleQueryResult(query: string, chartType: string): QueryResult { + // 시간에 따라 약간씩 다른 데이터 생성 (실시간 업데이트 시뮬레이션) + const timeVariation = Math.sin(Date.now() / 10000) * 0.1 + 1; + + const isMonthly = query.toLowerCase().includes('month'); + const isSales = query.toLowerCase().includes('sales') || query.toLowerCase().includes('매출'); + const isUsers = query.toLowerCase().includes('users') || query.toLowerCase().includes('사용자'); + const isProducts = query.toLowerCase().includes('product') || query.toLowerCase().includes('상품'); + const isWeekly = query.toLowerCase().includes('week'); + + let columns: string[]; + let rows: Record[]; + + if (isMonthly && isSales) { + columns = ['month', 'sales', 'order_count']; + rows = [ + { month: '2024-01', sales: Math.round(1200000 * timeVariation), order_count: Math.round(45 * timeVariation) }, + { month: '2024-02', sales: Math.round(1350000 * timeVariation), order_count: Math.round(52 * timeVariation) }, + { month: '2024-03', sales: Math.round(1180000 * timeVariation), order_count: Math.round(41 * timeVariation) }, + { month: '2024-04', sales: Math.round(1420000 * timeVariation), order_count: Math.round(58 * timeVariation) }, + { month: '2024-05', sales: Math.round(1680000 * timeVariation), order_count: Math.round(67 * timeVariation) }, + { month: '2024-06', sales: Math.round(1540000 * timeVariation), order_count: Math.round(61 * timeVariation) }, + ]; + } else if (isWeekly && isUsers) { + columns = ['week', 'new_users']; + rows = [ + { week: '2024-W10', new_users: Math.round(23 * timeVariation) }, + { week: '2024-W11', new_users: Math.round(31 * timeVariation) }, + { week: '2024-W12', new_users: Math.round(28 * timeVariation) }, + { week: '2024-W13', new_users: Math.round(35 * timeVariation) }, + { week: '2024-W14', new_users: Math.round(42 * timeVariation) }, + { week: '2024-W15', new_users: Math.round(38 * timeVariation) }, + ]; + } else if (isProducts) { + columns = ['product_name', 'total_sold', 'revenue']; + rows = [ + { product_name: '스마트폰', total_sold: Math.round(156 * timeVariation), revenue: Math.round(234000000 * timeVariation) }, + { product_name: '노트북', total_sold: Math.round(89 * timeVariation), revenue: Math.round(178000000 * timeVariation) }, + { product_name: '태블릿', total_sold: Math.round(134 * timeVariation), revenue: Math.round(67000000 * timeVariation) }, + { product_name: '이어폰', total_sold: Math.round(267 * timeVariation), revenue: Math.round(26700000 * timeVariation) }, + { product_name: '스마트워치', total_sold: Math.round(98 * timeVariation), revenue: Math.round(49000000 * timeVariation) }, + ]; + } else { + columns = ['category', 'value', 'count']; + rows = [ + { category: 'A', value: Math.round(100 * timeVariation), count: Math.round(10 * timeVariation) }, + { category: 'B', value: Math.round(150 * timeVariation), count: Math.round(15 * timeVariation) }, + { category: 'C', value: Math.round(120 * timeVariation), count: Math.round(12 * timeVariation) }, + { category: 'D', value: Math.round(180 * timeVariation), count: Math.round(18 * timeVariation) }, + { category: 'E', value: Math.round(90 * timeVariation), count: Math.round(9 * timeVariation) }, + ]; + } + + return { + columns, + rows, + totalRows: rows.length, + executionTime: Math.floor(Math.random() * 100) + 50, + }; +} diff --git a/frontend/lib/api/dashboard.ts b/frontend/lib/api/dashboard.ts new file mode 100644 index 00000000..9cc8ee5b --- /dev/null +++ b/frontend/lib/api/dashboard.ts @@ -0,0 +1,281 @@ +/** + * 대시보드 API 클라이언트 + */ + +import { DashboardElement } from '@/components/admin/dashboard/types'; + +// API 기본 설정 +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +// 토큰 가져오기 (실제 인증 시스템에 맞게 수정) +function getAuthToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem('authToken') || sessionStorage.getItem('authToken'); +} + +// API 요청 헬퍼 +async function apiRequest( + endpoint: string, + options: RequestInit = {} +): Promise<{ success: boolean; data?: T; message?: string; pagination?: any }> { + const token = getAuthToken(); + + const config: RequestInit = { + headers: { + 'Content-Type': 'application/json', + ...(token && { 'Authorization': `Bearer ${token}` }), + ...options.headers, + }, + ...options, + }; + + try { + const response = await fetch(`${API_BASE_URL}${endpoint}`, config); + + // 응답이 JSON이 아닐 수도 있으므로 안전하게 처리 + let result; + try { + result = await response.json(); + } catch (jsonError) { + console.error('JSON Parse Error:', jsonError); + throw new Error(`서버 응답을 파싱할 수 없습니다. Status: ${response.status}`); + } + + if (!response.ok) { + console.error('API Error Response:', { + status: response.status, + statusText: response.statusText, + result + }); + throw new Error(result.message || `HTTP ${response.status}: ${response.statusText}`); + } + + return result; + } catch (error: any) { + console.error('API Request Error:', { + endpoint, + error: error?.message || error, + errorObj: error, + config + }); + throw error; + } +} + +// 대시보드 타입 정의 +export interface Dashboard { + id: string; + title: string; + description?: string; + thumbnailUrl?: string; + isPublic: boolean; + createdBy: string; + createdAt: string; + updatedAt: string; + tags?: string[]; + category?: string; + viewCount: number; + elementsCount?: number; + creatorName?: string; + elements?: DashboardElement[]; +} + +export interface CreateDashboardRequest { + title: string; + description?: string; + isPublic?: boolean; + elements: DashboardElement[]; + tags?: string[]; + category?: string; +} + +export interface DashboardListQuery { + page?: number; + limit?: number; + search?: string; + category?: string; + isPublic?: boolean; +} + +// 대시보드 API 함수들 +export const dashboardApi = { + + /** + * 대시보드 생성 + */ + async createDashboard(data: CreateDashboardRequest): Promise { + const result = await apiRequest('/dashboards', { + method: 'POST', + body: JSON.stringify(data), + }); + + if (!result.success || !result.data) { + throw new Error(result.message || '대시보드 생성에 실패했습니다.'); + } + + return result.data; + }, + + /** + * 대시보드 목록 조회 + */ + async getDashboards(query: DashboardListQuery = {}) { + const params = new URLSearchParams(); + + if (query.page) params.append('page', query.page.toString()); + if (query.limit) params.append('limit', query.limit.toString()); + if (query.search) params.append('search', query.search); + if (query.category) params.append('category', query.category); + if (typeof query.isPublic === 'boolean') params.append('isPublic', query.isPublic.toString()); + + const queryString = params.toString(); + const endpoint = `/dashboards${queryString ? `?${queryString}` : ''}`; + + const result = await apiRequest(endpoint); + + if (!result.success) { + throw new Error(result.message || '대시보드 목록 조회에 실패했습니다.'); + } + + return { + dashboards: result.data || [], + pagination: result.pagination + }; + }, + + /** + * 내 대시보드 목록 조회 + */ + async getMyDashboards(query: DashboardListQuery = {}) { + const params = new URLSearchParams(); + + if (query.page) params.append('page', query.page.toString()); + if (query.limit) params.append('limit', query.limit.toString()); + if (query.search) params.append('search', query.search); + if (query.category) params.append('category', query.category); + + const queryString = params.toString(); + const endpoint = `/dashboards/my${queryString ? `?${queryString}` : ''}`; + + const result = await apiRequest(endpoint); + + if (!result.success) { + throw new Error(result.message || '내 대시보드 목록 조회에 실패했습니다.'); + } + + return { + dashboards: result.data || [], + pagination: result.pagination + }; + }, + + /** + * 대시보드 상세 조회 + */ + async getDashboard(id: string): Promise { + const result = await apiRequest(`/dashboards/${id}`); + + if (!result.success || !result.data) { + throw new Error(result.message || '대시보드 조회에 실패했습니다.'); + } + + return result.data; + }, + + /** + * 공개 대시보드 조회 (인증 불필요) + */ + async getPublicDashboard(id: string): Promise { + const result = await apiRequest(`/dashboards/public/${id}`); + + if (!result.success || !result.data) { + throw new Error(result.message || '대시보드 조회에 실패했습니다.'); + } + + return result.data; + }, + + /** + * 대시보드 수정 + */ + async updateDashboard(id: string, data: Partial): Promise { + const result = await apiRequest(`/dashboards/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + + if (!result.success || !result.data) { + throw new Error(result.message || '대시보드 수정에 실패했습니다.'); + } + + return result.data; + }, + + /** + * 대시보드 삭제 + */ + async deleteDashboard(id: string): Promise { + const result = await apiRequest(`/dashboards/${id}`, { + method: 'DELETE', + }); + + if (!result.success) { + throw new Error(result.message || '대시보드 삭제에 실패했습니다.'); + } + }, + + /** + * 공개 대시보드 목록 조회 (인증 불필요) + */ + async getPublicDashboards(query: DashboardListQuery = {}) { + const params = new URLSearchParams(); + + if (query.page) params.append('page', query.page.toString()); + if (query.limit) params.append('limit', query.limit.toString()); + if (query.search) params.append('search', query.search); + if (query.category) params.append('category', query.category); + + const queryString = params.toString(); + const endpoint = `/dashboards/public${queryString ? `?${queryString}` : ''}`; + + const result = await apiRequest(endpoint); + + if (!result.success) { + throw new Error(result.message || '공개 대시보드 목록 조회에 실패했습니다.'); + } + + return { + dashboards: result.data || [], + pagination: result.pagination + }; + }, + + /** + * 쿼리 실행 (차트 데이터 조회) + */ + async executeQuery(query: string): Promise<{ columns: string[]; rows: any[]; rowCount: number }> { + const result = await apiRequest<{ columns: string[]; rows: any[]; rowCount: number }>('/dashboards/execute-query', { + method: 'POST', + body: JSON.stringify({ query }), + }); + + if (!result.success || !result.data) { + throw new Error(result.message || '쿼리 실행에 실패했습니다.'); + } + + return result.data; + } +}; + +// 에러 처리 유틸리티 +export function handleApiError(error: any): string { + if (error.message) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + return '알 수 없는 오류가 발생했습니다.'; +}