diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 4d66d052..a5eef350 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -3450,6 +3450,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/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)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx new file mode 100644 index 00000000..cd65cd8a --- /dev/null +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -0,0 +1,18 @@ +'use client'; + +import React from 'react'; +import DashboardDesigner from '@/components/admin/dashboard/DashboardDesigner'; + +/** + * 대시보드 관리 페이지 + * - 드래그 앤 드롭으로 대시보드 레이아웃 설계 + * - 차트 및 위젯 배치 관리 + * - 독립적인 컴포넌트로 구성되어 다른 시스템에 영향 없음 + */ +export default function DashboardPage() { + return ( +
+ +
+ ); +} 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 new file mode 100644 index 00000000..d7074aec --- /dev/null +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -0,0 +1,398 @@ +'use client'; + +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { DashboardElement, QueryResult } from './types'; +import { ChartRenderer } from './charts/ChartRenderer'; + +interface CanvasElementProps { + element: DashboardElement; + isSelected: boolean; + onUpdate: (id: string, updates: Partial) => void; + onRemove: (id: string) => void; + onSelect: (id: string | null) => void; + onConfigure?: (element: DashboardElement) => void; +} + +/** + * 캔버스에 배치된 개별 요소 컴포넌트 + * - 드래그로 이동 가능 + * - 크기 조절 핸들 + * - 삭제 버튼 + */ +export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelect, onConfigure }: CanvasElementProps) { + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 }); + const [resizeStart, setResizeStart] = useState({ + x: 0, y: 0, width: 0, height: 0, elementX: 0, elementY: 0, handle: '' + }); + const [chartData, setChartData] = useState(null); + const [isLoadingData, setIsLoadingData] = useState(false); + const elementRef = useRef(null); + + // 요소 선택 처리 + const handleMouseDown = useCallback((e: React.MouseEvent) => { + // 닫기 버튼이나 리사이즈 핸들 클릭 시 무시 + if ((e.target as HTMLElement).closest('.element-close, .resize-handle')) { + return; + } + + onSelect(element.id); + setIsDragging(true); + setDragStart({ + x: e.clientX, + y: e.clientY, + elementX: element.position.x, + elementY: element.position.y + }); + e.preventDefault(); + }, [element.id, element.position.x, element.position.y, onSelect]); + + // 리사이즈 핸들 마우스다운 + const handleResizeMouseDown = useCallback((e: React.MouseEvent, handle: string) => { + e.stopPropagation(); + setIsResizing(true); + setResizeStart({ + x: e.clientX, + y: e.clientY, + width: element.size.width, + height: element.size.height, + elementX: element.position.x, + elementY: element.position.y, + handle + }); + }, [element.size.width, element.size.height, element.position.x, element.position.y]); + + // 마우스 이동 처리 + const handleMouseMove = useCallback((e: MouseEvent) => { + if (isDragging) { + const deltaX = e.clientX - dragStart.x; + const deltaY = e.clientY - dragStart.y; + + onUpdate(element.id, { + position: { + x: Math.max(0, dragStart.elementX + deltaX), + y: Math.max(0, dragStart.elementY + deltaY) + } + }); + } else if (isResizing) { + const deltaX = e.clientX - resizeStart.x; + const deltaY = e.clientY - resizeStart.y; + + let newWidth = resizeStart.width; + let newHeight = resizeStart.height; + let newX = resizeStart.elementX; + let newY = resizeStart.elementY; + + switch (resizeStart.handle) { + case 'se': // 오른쪽 아래 + newWidth = Math.max(150, resizeStart.width + deltaX); + newHeight = Math.max(150, resizeStart.height + deltaY); + break; + case 'sw': // 왼쪽 아래 + newWidth = Math.max(150, resizeStart.width - deltaX); + newHeight = Math.max(150, resizeStart.height + deltaY); + newX = resizeStart.elementX + deltaX; + break; + case 'ne': // 오른쪽 위 + newWidth = Math.max(150, resizeStart.width + deltaX); + newHeight = Math.max(150, resizeStart.height - deltaY); + newY = resizeStart.elementY + deltaY; + break; + case 'nw': // 왼쪽 위 + newWidth = Math.max(150, resizeStart.width - deltaX); + newHeight = Math.max(150, resizeStart.height - deltaY); + newX = resizeStart.elementX + deltaX; + newY = resizeStart.elementY + deltaY; + break; + } + + onUpdate(element.id, { + position: { x: Math.max(0, newX), y: Math.max(0, newY) }, + size: { width: newWidth, height: newHeight } + }); + } + }, [isDragging, isResizing, dragStart, resizeStart, element.id, onUpdate]); + + // 마우스 업 처리 + const handleMouseUp = useCallback(() => { + setIsDragging(false); + setIsResizing(false); + }, []); + + // 전역 마우스 이벤트 등록 + React.useEffect(() => { + if (isDragging || isResizing) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + } + }, [isDragging, isResizing, handleMouseMove, handleMouseUp]); + + // 데이터 로딩 + const loadChartData = useCallback(async () => { + if (!element.dataSource?.query || element.type !== 'chart') { + return; + } + + setIsLoadingData(true); + try { + // 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); + setChartData(null); + } finally { + setIsLoadingData(false); + } + }, [element.dataSource?.query, element.type, element.subtype]); + + // 컴포넌트 마운트 시 및 쿼리 변경 시 데이터 로딩 + useEffect(() => { + loadChartData(); + }, [loadChartData]); + + // 자동 새로고침 설정 + useEffect(() => { + if (!element.dataSource?.refreshInterval || element.dataSource.refreshInterval === 0) { + return; + } + + const interval = setInterval(loadChartData, element.dataSource.refreshInterval); + return () => clearInterval(interval); + }, [element.dataSource?.refreshInterval, loadChartData]); + + // 요소 삭제 + const handleRemove = useCallback(() => { + onRemove(element.id); + }, [element.id, onRemove]); + + // 스타일 클래스 생성 + const getContentClass = () => { + if (element.type === 'chart') { + switch (element.subtype) { + case 'bar': return 'bg-gradient-to-br from-indigo-400 to-purple-600'; + case 'pie': return 'bg-gradient-to-br from-pink-400 to-red-500'; + case 'line': return 'bg-gradient-to-br from-blue-400 to-cyan-400'; + default: return 'bg-gray-200'; + } + } else if (element.type === 'widget') { + switch (element.subtype) { + case 'exchange': return 'bg-gradient-to-br from-pink-400 to-yellow-400'; + case 'weather': return 'bg-gradient-to-br from-cyan-400 to-indigo-800'; + default: return 'bg-gray-200'; + } + } + return 'bg-gray-200'; + }; + + return ( +
+ {/* 헤더 */} +
+ {element.title} +
+ {/* 설정 버튼 */} + {onConfigure && ( + + )} + {/* 삭제 버튼 */} + +
+
+ + {/* 내용 */} +
+ {element.type === 'chart' ? ( + // 차트 렌더링 +
+ {isLoadingData ? ( +
+
+
+
데이터 로딩 중...
+
+
+ ) : ( + + )} +
+ ) : ( + // 위젯 렌더링 (기존 방식) +
+
+
+ {element.type === 'widget' && element.subtype === 'exchange' && '💱'} + {element.type === 'widget' && element.subtype === 'weather' && '☁️'} +
+
{element.content}
+
+
+ )} +
+ + {/* 리사이즈 핸들 (선택된 요소에만 표시) */} + {isSelected && ( + <> + + + + + + )} +
+ ); +} + +interface ResizeHandleProps { + position: 'nw' | 'ne' | 'sw' | 'se'; + onMouseDown: (e: React.MouseEvent, handle: string) => void; +} + +/** + * 크기 조절 핸들 컴포넌트 + */ +function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) { + const getPositionClass = () => { + switch (position) { + case 'nw': return 'top-[-5px] left-[-5px] cursor-nw-resize'; + case 'ne': return 'top-[-5px] right-[-5px] cursor-ne-resize'; + case 'sw': return 'bottom-[-5px] left-[-5px] cursor-sw-resize'; + case 'se': return 'bottom-[-5px] right-[-5px] cursor-se-resize'; + } + }; + + return ( +
onMouseDown(e, position)} + /> + ); +} + +/** + * 샘플 데이터 생성 함수 (실제 API 호출 대신 사용) + */ +function generateSampleData(query: string, chartType: string): QueryResult { + // 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성 + 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('상품'); + + let columns: string[]; + let rows: Record[]; + + if (isMonthly && isSales) { + // 월별 매출 데이터 + columns = ['month', 'sales', 'order_count']; + rows = [ + { month: '2024-01', sales: 1200000, order_count: 45 }, + { month: '2024-02', sales: 1350000, order_count: 52 }, + { month: '2024-03', sales: 1180000, order_count: 41 }, + { month: '2024-04', sales: 1420000, order_count: 58 }, + { month: '2024-05', sales: 1680000, order_count: 67 }, + { month: '2024-06', sales: 1540000, order_count: 61 }, + ]; + } else if (isUsers) { + // 사용자 가입 추이 + columns = ['week', 'new_users']; + rows = [ + { week: '2024-W10', new_users: 23 }, + { week: '2024-W11', new_users: 31 }, + { week: '2024-W12', new_users: 28 }, + { week: '2024-W13', new_users: 35 }, + { week: '2024-W14', new_users: 42 }, + { week: '2024-W15', new_users: 38 }, + ]; + } else if (isProducts) { + // 상품별 판매량 + columns = ['product_name', 'total_sold', 'revenue']; + rows = [ + { product_name: '스마트폰', total_sold: 156, revenue: 234000000 }, + { product_name: '노트북', total_sold: 89, revenue: 178000000 }, + { product_name: '태블릿', total_sold: 134, revenue: 67000000 }, + { product_name: '이어폰', total_sold: 267, revenue: 26700000 }, + { product_name: '스마트워치', total_sold: 98, revenue: 49000000 }, + ]; + } else { + // 기본 샘플 데이터 + columns = ['category', 'value', 'count']; + rows = [ + { category: 'A', value: 100, count: 10 }, + { category: 'B', value: 150, count: 15 }, + { category: 'C', value: 120, count: 12 }, + { category: 'D', value: 180, count: 18 }, + { category: 'E', value: 90, count: 9 }, + ]; + } + + return { + columns, + rows, + totalRows: rows.length, + executionTime: Math.floor(Math.random() * 100) + 50, // 50-150ms + }; +} diff --git a/frontend/components/admin/dashboard/ChartConfigPanel.tsx b/frontend/components/admin/dashboard/ChartConfigPanel.tsx new file mode 100644 index 00000000..5ba617a3 --- /dev/null +++ b/frontend/components/admin/dashboard/ChartConfigPanel.tsx @@ -0,0 +1,262 @@ +'use client'; + +import React, { useState, useCallback } from 'react'; +import { ChartConfig, QueryResult } from './types'; + +interface ChartConfigPanelProps { + config?: ChartConfig; + queryResult?: QueryResult; + onConfigChange: (config: ChartConfig) => void; +} + +/** + * 차트 설정 패널 컴포넌트 + * - 데이터 필드 매핑 설정 + * - 차트 스타일 설정 + * - 실시간 미리보기 + */ +export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartConfigPanelProps) { + const [currentConfig, setCurrentConfig] = useState(config || {}); + + // 설정 업데이트 + const updateConfig = useCallback((updates: Partial) => { + const newConfig = { ...currentConfig, ...updates }; + setCurrentConfig(newConfig); + onConfigChange(newConfig); + }, [currentConfig, onConfigChange]); + + // 사용 가능한 컬럼 목록 + const availableColumns = queryResult?.columns || []; + const sampleData = queryResult?.rows?.[0] || {}; + + return ( +
+

⚙️ 차트 설정

+ + {/* 쿼리 결과가 없을 때 */} + {!queryResult && ( +
+
+ 💡 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 차트를 설정할 수 있습니다. +
+
+ )} + + {/* 데이터 필드 매핑 */} + {queryResult && ( + <> + {/* 차트 제목 */} +
+ + updateConfig({ title: e.target.value })} + placeholder="차트 제목을 입력하세요" + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +
+ + {/* X축 설정 */} +
+ + +
+ + {/* Y축 설정 (다중 선택 가능) */} +
+ +
+ {availableColumns.map((col) => { + const isSelected = Array.isArray(currentConfig.yAxis) + ? currentConfig.yAxis.includes(col) + : currentConfig.yAxis === col; + + return ( + + ); + })} +
+
+ 💡 팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰) +
+
+ + {/* 집계 함수 */} +
+ + +
+ 💡 집계 함수는 현재 쿼리 결과에 적용되지 않습니다. + SQL 쿼리에서 직접 집계하는 것을 권장합니다. +
+
+ + {/* 그룹핑 필드 (선택사항) */} +
+ + +
+ + {/* 차트 색상 */} +
+ +
+ {[ + ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'], // 기본 + ['#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'], // 밝은 + ['#1F2937', '#374151', '#6B7280', '#9CA3AF'], // 회색 + ['#DC2626', '#EA580C', '#CA8A04', '#65A30D'], // 따뜻한 + ].map((colorSet, setIdx) => ( + + ))} +
+
+ + {/* 범례 표시 */} +
+ updateConfig({ showLegend: e.target.checked })} + className="rounded" + /> + +
+ + {/* 설정 미리보기 */} +
+
📋 설정 미리보기
+
+
X축: {currentConfig.xAxis || '미설정'}
+
+ 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 && ( +
+ ✨ 다중 시리즈 차트가 생성됩니다! +
+ )} +
+
+ + {/* 필수 필드 확인 */} + {(!currentConfig.xAxis || !currentConfig.yAxis) && ( +
+
+ ⚠️ X축과 Y축을 모두 설정해야 차트가 표시됩니다. +
+
+ )} + + )} +
+ ); +} diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx new file mode 100644 index 00000000..ed0b253f --- /dev/null +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -0,0 +1,109 @@ +'use client'; + +import React, { forwardRef, useState, useCallback } from 'react'; +import { DashboardElement, ElementType, ElementSubtype, DragData } from './types'; +import { CanvasElement } from './CanvasElement'; + +interface DashboardCanvasProps { + elements: DashboardElement[]; + selectedElement: string | null; + onCreateElement: (type: ElementType, subtype: ElementSubtype, x: number, y: number) => void; + onUpdateElement: (id: string, updates: Partial) => void; + onRemoveElement: (id: string) => void; + onSelectElement: (id: string | null) => void; + onConfigureElement?: (element: DashboardElement) => void; +} + +/** + * 대시보드 캔버스 컴포넌트 + * - 드래그 앤 드롭 영역 + * - 그리드 배경 + * - 요소 배치 및 관리 + */ +export const DashboardCanvas = forwardRef( + ({ elements, selectedElement, onCreateElement, onUpdateElement, onRemoveElement, onSelectElement, onConfigureElement }, ref) => { + const [isDragOver, setIsDragOver] = useState(false); + + // 드래그 오버 처리 + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + setIsDragOver(true); + }, []); + + // 드래그 리브 처리 + const handleDragLeave = useCallback((e: React.DragEvent) => { + if (e.currentTarget === e.target) { + setIsDragOver(false); + } + }, []); + + // 드롭 처리 + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + + try { + const dragData: DragData = JSON.parse(e.dataTransfer.getData('application/json')); + + if (!ref || typeof ref === 'function') return; + + const rect = ref.current?.getBoundingClientRect(); + if (!rect) return; + + // 캔버스 스크롤을 고려한 정확한 위치 계산 + const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0); + const y = e.clientY - rect.top + (ref.current?.scrollTop || 0); + + onCreateElement(dragData.type, dragData.subtype, x, y); + } catch (error) { + // console.error('드롭 데이터 파싱 오류:', error); + } + }, [ref, onCreateElement]); + + // 캔버스 클릭 시 선택 해제 + const handleCanvasClick = useCallback((e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onSelectElement(null); + } + }, [onSelectElement]); + + return ( +
+ {/* 배치된 요소들 렌더링 */} + {elements.map((element) => ( + + ))} +
+ ); + } +); + +DashboardCanvas.displayName = 'DashboardCanvas'; diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx new file mode 100644 index 00000000..e13209f1 --- /dev/null +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -0,0 +1,297 @@ +'use client'; + +import React, { useState, useRef, useCallback } from 'react'; +import { DashboardCanvas } from './DashboardCanvas'; +import { DashboardSidebar } from './DashboardSidebar'; +import { DashboardToolbar } from './DashboardToolbar'; +import { ElementConfigModal } from './ElementConfigModal'; +import { DashboardElement, ElementType, ElementSubtype } from './types'; + +/** + * 대시보드 설계 도구 메인 컴포넌트 + * - 드래그 앤 드롭으로 차트/위젯 배치 + * - 요소 이동, 크기 조절, 삭제 기능 + * - 레이아웃 저장/불러오기 기능 + */ +export default function DashboardDesigner() { + const [elements, setElements] = useState([]); + 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, + subtype: ElementSubtype, + x: number, + y: number + ) => { + const newElement: DashboardElement = { + id: `element-${elementCounter + 1}`, + type, + subtype, + position: { x, y }, + size: { width: 250, height: 200 }, + title: getElementTitle(type, subtype), + content: getElementContent(type, subtype) + }; + + setElements(prev => [...prev, newElement]); + setElementCounter(prev => prev + 1); + setSelectedElement(newElement.id); + }, [elementCounter]); + + // 요소 업데이트 + const updateElement = useCallback((id: string, updates: Partial) => { + setElements(prev => prev.map(el => + el.id === id ? { ...el, ...updates } : el + )); + }, []); + + // 요소 삭제 + const removeElement = useCallback((id: string) => { + setElements(prev => prev.filter(el => el.id !== id)); + if (selectedElement === id) { + setSelectedElement(null); + } + }, [selectedElement]); + + // 전체 삭제 + const clearCanvas = useCallback(() => { + if (window.confirm('모든 요소를 삭제하시겠습니까?')) { + setElements([]); + setSelectedElement(null); + setElementCounter(0); + } + }, []); + + // 요소 설정 모달 열기 + const openConfigModal = useCallback((element: DashboardElement) => { + setConfigModalElement(element); + }, []); + + // 요소 설정 모달 닫기 + const closeConfigModal = useCallback(() => { + setConfigModalElement(null); + }, []); + + // 요소 설정 저장 + const saveElementConfig = useCallback((updatedElement: DashboardElement) => { + updateElement(updatedElement.id, updatedElement); + }, [updateElement]); + + // 레이아웃 저장 + 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 + })); + + 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} +
+ )} + + + +
+ + {/* 사이드바 */} + + + {/* 요소 설정 모달 */} + {configModalElement && ( + + )} +
+ ); +} + +// 요소 제목 생성 헬퍼 함수 +function getElementTitle(type: ElementType, subtype: ElementSubtype): string { + if (type === 'chart') { + switch (subtype) { + case 'bar': return '📊 바 차트'; + case 'pie': return '🥧 원형 차트'; + case 'line': return '📈 꺾은선 차트'; + default: return '📊 차트'; + } + } else if (type === 'widget') { + switch (subtype) { + case 'exchange': return '💱 환율 위젯'; + case 'weather': return '☁️ 날씨 위젯'; + default: return '🔧 위젯'; + } + } + return '요소'; +} + +// 요소 내용 생성 헬퍼 함수 +function getElementContent(type: ElementType, subtype: ElementSubtype): string { + if (type === 'chart') { + switch (subtype) { + case 'bar': return '바 차트가 여기에 표시됩니다'; + case 'pie': return '원형 차트가 여기에 표시됩니다'; + case 'line': return '꺾은선 차트가 여기에 표시됩니다'; + default: return '차트가 여기에 표시됩니다'; + } + } else if (type === 'widget') { + switch (subtype) { + case 'exchange': return 'USD: ₩1,320\nJPY: ₩900\nEUR: ₩1,450'; + case 'weather': return '서울\n23°C\n구름 많음'; + default: return '위젯 내용이 여기에 표시됩니다'; + } + } + return '내용이 여기에 표시됩니다'; +} diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx new file mode 100644 index 00000000..a10cbc7c --- /dev/null +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -0,0 +1,145 @@ +'use client'; + +import React from 'react'; +import { DragData, ElementType, ElementSubtype } from './types'; + +/** + * 대시보드 사이드바 컴포넌트 + * - 드래그 가능한 차트/위젯 목록 + * - 카테고리별 구분 + */ +export function DashboardSidebar() { + // 드래그 시작 처리 + const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => { + const dragData: DragData = { type, subtype }; + e.dataTransfer.setData('application/json', JSON.stringify(dragData)); + e.dataTransfer.effectAllowed = 'copy'; + }; + + return ( +
+ {/* 차트 섹션 */} +
+

+ 📊 차트 종류 +

+ +
+ + + + + + + +
+
+ + {/* 위젯 섹션 */} +
+

+ 🔧 위젯 종류 +

+ +
+ + +
+
+
+ ); +} + +interface DraggableItemProps { + icon: string; + title: string; + type: ElementType; + subtype: ElementSubtype; + className?: string; + onDragStart: (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => void; +} + +/** + * 드래그 가능한 아이템 컴포넌트 + */ +function DraggableItem({ icon, title, type, subtype, className = '', onDragStart }: DraggableItemProps) { + return ( +
onDragStart(e, type, subtype)} + > + {icon} + {title} +
+ ); +} diff --git a/frontend/components/admin/dashboard/DashboardToolbar.tsx b/frontend/components/admin/dashboard/DashboardToolbar.tsx new file mode 100644 index 00000000..59a7584a --- /dev/null +++ b/frontend/components/admin/dashboard/DashboardToolbar.tsx @@ -0,0 +1,42 @@ +'use client'; + +import React from 'react'; + +interface DashboardToolbarProps { + onClearCanvas: () => void; + onSaveLayout: () => void; +} + +/** + * 대시보드 툴바 컴포넌트 + * - 전체 삭제, 레이아웃 저장 등 주요 액션 버튼 + */ +export function DashboardToolbar({ onClearCanvas, onSaveLayout }: DashboardToolbarProps) { + return ( +
+ + + +
+ ); +} diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx new file mode 100644 index 00000000..765b4ef1 --- /dev/null +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -0,0 +1,169 @@ +'use client'; + +import React, { useState, useCallback } from 'react'; +import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from './types'; +import { QueryEditor } from './QueryEditor'; +import { ChartConfigPanel } from './ChartConfigPanel'; + +interface ElementConfigModalProps { + element: DashboardElement; + isOpen: boolean; + onClose: () => void; + onSave: (element: DashboardElement) => void; +} + +/** + * 요소 설정 모달 컴포넌트 + * - 차트/위젯 데이터 소스 설정 + * - 쿼리 에디터 통합 + * - 차트 설정 패널 통합 + */ +export function ElementConfigModal({ element, isOpen, onClose, onSave }: ElementConfigModalProps) { + const [dataSource, setDataSource] = useState( + element.dataSource || { type: 'database', refreshInterval: 30000 } + ); + const [chartConfig, setChartConfig] = useState( + element.chartConfig || {} + ); + const [queryResult, setQueryResult] = useState(null); + const [activeTab, setActiveTab] = useState<'query' | 'chart'>('query'); + + // 데이터 소스 변경 처리 + const handleDataSourceChange = useCallback((newDataSource: ChartDataSource) => { + setDataSource(newDataSource); + }, []); + + // 차트 설정 변경 처리 + const handleChartConfigChange = useCallback((newConfig: ChartConfig) => { + setChartConfig(newConfig); + }, []); + + // 쿼리 테스트 결과 처리 + const handleQueryTest = useCallback((result: QueryResult) => { + setQueryResult(result); + // 쿼리 결과가 나오면 자동으로 차트 설정 탭으로 이동 + if (result.rows.length > 0) { + setActiveTab('chart'); + } + }, []); + + // 저장 처리 + const handleSave = useCallback(() => { + const updatedElement: DashboardElement = { + ...element, + dataSource, + chartConfig, + }; + onSave(updatedElement); + onClose(); + }, [element, dataSource, chartConfig, onSave, onClose]); + + // 모달이 열려있지 않으면 렌더링하지 않음 + if (!isOpen) return null; + + return ( +
+
+ {/* 모달 헤더 */} +
+
+

+ {element.title} 설정 +

+

+ 데이터 소스와 차트 설정을 구성하세요 +

+
+ +
+ + {/* 탭 네비게이션 */} +
+ + +
+ + {/* 탭 내용 */} +
+ {activeTab === 'query' && ( + + )} + + {activeTab === 'chart' && ( + + )} +
+ + {/* 모달 푸터 */} +
+
+ {dataSource.query && ( + <> + 💾 쿼리: {dataSource.query.length > 50 + ? `${dataSource.query.substring(0, 50)}...` + : dataSource.query} + + )} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/frontend/components/admin/dashboard/QueryEditor.tsx b/frontend/components/admin/dashboard/QueryEditor.tsx new file mode 100644 index 00000000..671024cd --- /dev/null +++ b/frontend/components/admin/dashboard/QueryEditor.tsx @@ -0,0 +1,489 @@ +'use client'; + +import React, { useState, useCallback } from 'react'; +import { ChartDataSource, QueryResult } from './types'; + +interface QueryEditorProps { + dataSource?: ChartDataSource; + onDataSourceChange: (dataSource: ChartDataSource) => void; + onQueryTest?: (result: QueryResult) => void; +} + +/** + * SQL 쿼리 에디터 컴포넌트 + * - SQL 쿼리 작성 및 편집 + * - 쿼리 실행 및 결과 미리보기 + * - 데이터 소스 설정 + */ +export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: QueryEditorProps) { + const [query, setQuery] = useState(dataSource?.query || ''); + const [isExecuting, setIsExecuting] = useState(false); + const [queryResult, setQueryResult] = useState(null); + const [error, setError] = useState(null); + + // 쿼리 실행 + const executeQuery = useCallback(async () => { + if (!query.trim()) { + setError('쿼리를 입력해주세요.'); + return; + } + + setIsExecuting(true); + setError(null); + + try { + // 실제 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으로 설정 + }; + + setQueryResult(result); + onQueryTest?.(result); + + // 데이터 소스 업데이트 + onDataSourceChange({ + type: 'database', + query: query.trim(), + refreshInterval: dataSource?.refreshInterval || 30000, + lastExecuted: new Date().toISOString() + }); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '쿼리 실행 중 오류가 발생했습니다.'; + setError(errorMessage); + // console.error('Query execution error:', err); + } finally { + setIsExecuting(false); + } + }, [query, dataSource?.refreshInterval, onDataSourceChange, onQueryTest]); + + // 샘플 쿼리 삽입 + 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, + SUM(total_amount) as sales, + COUNT(*) as order_count +FROM orders +WHERE order_date >= CURRENT_DATE - INTERVAL '12 months' +GROUP BY DATE_TRUNC('month', order_date) +ORDER BY month;`, + + users: `-- 사용자 가입 추이 +SELECT + DATE_TRUNC('week', created_at) as week, + COUNT(*) as new_users +FROM users +WHERE created_at >= CURRENT_DATE - INTERVAL '3 months' +GROUP BY DATE_TRUNC('week', created_at) +ORDER BY week;`, + + products: `-- 상품별 판매량 +SELECT + product_name, + SUM(quantity) as total_sold, + SUM(quantity * price) as revenue +FROM order_items oi +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;`, + + 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] || ''); + }, []); + + return ( +
+ {/* 쿼리 에디터 헤더 */} +
+

📝 SQL 쿼리 에디터

+
+ +
+
+ + {/* 샘플 쿼리 버튼들 */} +
+ 샘플 쿼리: + + + + + +
+ + {/* SQL 쿼리 입력 영역 */} +
+