From 579d4224d5b446c47d7122b7c107ddf4c3035788 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 1 Oct 2025 13:53:45 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=8B=A4=ED=96=89=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=97=90=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EB=B0=94=EC=9D=B8=EB=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/reportController.ts | 28 ++ backend-node/src/routes/reportRoutes.ts | 6 +- backend-node/src/services/reportService.ts | 165 ++++++- backend-node/src/types/report.ts | 26 +- .../report/designer/CanvasComponent.tsx | 102 ++++- .../report/designer/QueryManager.tsx | 354 ++++++++++++++ .../designer/ReportDesignerLeftPanel.tsx | 2 +- .../designer/ReportDesignerRightPanel.tsx | 432 ++++++++++++------ frontend/contexts/ReportDesignerContext.tsx | 80 +++- frontend/lib/api/reportApi.ts | 12 + frontend/types/report.ts | 23 + 11 files changed, 1062 insertions(+), 168 deletions(-) create mode 100644 frontend/components/report/designer/QueryManager.tsx diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index 02d79030..d188758b 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -327,6 +327,34 @@ export class ReportController { return next(error); } } + + /** + * 쿼리 실행 + * POST /api/admin/reports/:reportId/queries/:queryId/execute + */ + async executeQuery(req: Request, res: Response, next: NextFunction) { + try { + const { reportId, queryId } = req.params; + const { parameters = {}, sqlQuery } = req.body; + + const result = await reportService.executeQuery( + reportId, + queryId, + parameters, + sqlQuery + ); + + return res.json({ + success: true, + data: result, + }); + } catch (error: any) { + return res.status(400).json({ + success: false, + message: error.message || "쿼리 실행에 실패했습니다.", + }); + } + } } export default new ReportController(); diff --git a/backend-node/src/routes/reportRoutes.ts b/backend-node/src/routes/reportRoutes.ts index aa4bcb29..44b20993 100644 --- a/backend-node/src/routes/reportRoutes.ts +++ b/backend-node/src/routes/reportRoutes.ts @@ -41,6 +41,11 @@ router.put("/:reportId/layout", (req, res, next) => reportController.saveLayout(req, res, next) ); +// 쿼리 실행 +router.post("/:reportId/queries/:queryId/execute", (req, res, next) => + reportController.executeQuery(req, res, next) +); + // 리포트 상세 router.get("/:reportId", (req, res, next) => reportController.getReportById(req, res, next) @@ -57,4 +62,3 @@ router.delete("/:reportId", (req, res, next) => ); export default router; - diff --git a/backend-node/src/services/reportService.ts b/backend-node/src/services/reportService.ts index f6bf72d3..2c49ab51 100644 --- a/backend-node/src/services/reportService.ts +++ b/backend-node/src/services/reportService.ts @@ -7,6 +7,7 @@ import { query, queryOne, transaction } from "../database/db"; import { ReportMaster, ReportLayout, + ReportQuery, ReportTemplate, ReportDetail, GetReportsParams, @@ -155,9 +156,30 @@ export class ReportService { `; const layout = await queryOne(layoutQuery, [reportId]); + // 쿼리 조회 + const queriesQuery = ` + SELECT + query_id, + report_id, + query_name, + query_type, + sql_query, + parameters, + display_order, + created_at, + created_by, + updated_at, + updated_by + FROM report_query + WHERE report_id = $1 + ORDER BY display_order, created_at + `; + const queries = await query(queriesQuery, [reportId]); + return { report, layout, + queries: queries || [], }; } @@ -306,7 +328,12 @@ export class ReportService { */ async deleteReport(reportId: string): Promise { return transaction(async (client) => { - // 레이아웃 삭제 (CASCADE로 자동 삭제되지만 명시적으로) + // 쿼리 삭제 (CASCADE로 자동 삭제되지만 명시적으로) + await client.query(`DELETE FROM report_query WHERE report_id = $1`, [ + reportId, + ]); + + // 레이아웃 삭제 await client.query(`DELETE FROM report_layout WHERE report_id = $1`, [ reportId, ]); @@ -407,6 +434,41 @@ export class ReportService { ]); } + // 쿼리 복사 + const queriesQuery = ` + SELECT * FROM report_query WHERE report_id = $1 ORDER BY display_order + `; + const queriesResult = await client.query(queriesQuery, [reportId]); + + if (queriesResult.rows.length > 0) { + const copyQuerySql = ` + INSERT INTO report_query ( + query_id, + report_id, + query_name, + query_type, + sql_query, + parameters, + display_order, + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `; + + for (const originalQuery of queriesResult.rows) { + const newQueryId = `QRY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + await client.query(copyQuerySql, [ + newQueryId, + newReportId, + originalQuery.query_name, + originalQuery.query_type, + originalQuery.sql_query, + originalQuery.parameters, + originalQuery.display_order, + userId, + ]); + } + } + return newReportId; }); } @@ -439,7 +501,7 @@ export class ReportService { } /** - * 레이아웃 저장 + * 레이아웃 저장 (쿼리 포함) */ async saveLayout( reportId: string, @@ -447,7 +509,7 @@ export class ReportService { userId: string ): Promise { return transaction(async (client) => { - // 기존 레이아웃 확인 + // 1. 레이아웃 저장 const existingQuery = ` SELECT layout_id FROM report_layout WHERE report_id = $1 `; @@ -517,10 +579,107 @@ export class ReportService { ]); } + // 2. 쿼리 저장 (있는 경우) + if (data.queries && data.queries.length > 0) { + // 기존 쿼리 모두 삭제 + await client.query(`DELETE FROM report_query WHERE report_id = $1`, [ + reportId, + ]); + + // 새 쿼리 삽입 + const insertQuerySql = ` + INSERT INTO report_query ( + query_id, + report_id, + query_name, + query_type, + sql_query, + parameters, + display_order, + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `; + + for (let i = 0; i < data.queries.length; i++) { + const q = data.queries[i]; + await client.query(insertQuerySql, [ + q.id, + reportId, + q.name, + q.type, + q.sqlQuery, + JSON.stringify(q.parameters), + i, + userId, + ]); + } + } + return true; }); } + /** + * 쿼리 실행 + */ + async executeQuery( + reportId: string, + queryId: string, + parameters: Record, + sqlQuery?: string + ): Promise<{ fields: string[]; rows: any[] }> { + let sql_query: string; + let queryParameters: string[] = []; + + // 테스트 모드 (sqlQuery 직접 전달) + if (sqlQuery) { + sql_query = sqlQuery; + // 파라미터 순서 추출 + const matches = sqlQuery.match(/\$\d+/g); + if (matches) { + queryParameters = Array.from(new Set(matches)).sort((a, b) => { + return parseInt(a.substring(1)) - parseInt(b.substring(1)); + }); + } + } else { + // DB에서 쿼리 조회 + const queryResult = await queryOne( + `SELECT * FROM report_query WHERE query_id = $1 AND report_id = $2`, + [queryId, reportId] + ); + + if (!queryResult) { + throw new Error("쿼리를 찾을 수 없습니다."); + } + + sql_query = queryResult.sql_query; + queryParameters = Array.isArray(queryResult.parameters) + ? queryResult.parameters + : []; + } + + // 파라미터 배열 생성 ($1, $2 순서대로) + const paramArray: any[] = []; + for (const param of queryParameters) { + paramArray.push(parameters[param] || null); + } + + try { + // 쿼리 실행 + const result = await query(sql_query, paramArray); + + // 필드명 추출 + const fields = result.length > 0 ? Object.keys(result[0]) : []; + + return { + fields, + rows: result, + }; + } catch (error: any) { + throw new Error(`쿼리 실행 오류: ${error.message}`); + } + } + /** * 템플릿 목록 조회 */ diff --git a/backend-node/src/types/report.ts b/backend-node/src/types/report.ts index ab471298..0b492734 100644 --- a/backend-node/src/types/report.ts +++ b/backend-node/src/types/report.ts @@ -55,10 +55,26 @@ export interface ReportLayout { updated_by: string | null; } -// 리포트 상세 (마스터 + 레이아웃) +// 리포트 쿼리 +export interface ReportQuery { + query_id: string; + report_id: string; + query_name: string; + query_type: "MASTER" | "DETAIL"; + sql_query: string; + parameters: string[] | null; + display_order: number; + created_at: Date; + created_by: string | null; + updated_at: Date | null; + updated_by: string | null; +} + +// 리포트 상세 (마스터 + 레이아웃 + 쿼리) export interface ReportDetail { report: ReportMaster; layout: ReportLayout | null; + queries: ReportQuery[]; } // 리포트 목록 조회 파라미터 @@ -109,6 +125,13 @@ export interface SaveLayoutRequest { marginLeft: number; marginRight: number; components: any[]; + queries?: Array<{ + id: string; + name: string; + type: "MASTER" | "DETAIL"; + sqlQuery: string; + parameters: string[]; + }>; } // 템플릿 목록 응답 @@ -126,4 +149,3 @@ export interface CreateTemplateRequest { layoutConfig?: any; defaultQueries?: any; } - diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 6ca89cec..efb8107b 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -9,7 +9,7 @@ interface CanvasComponentProps { } export function CanvasComponent({ component }: CanvasComponentProps) { - const { selectedComponentId, selectComponent, updateComponent } = useReportDesigner(); + const { selectedComponentId, selectComponent, updateComponent, getQueryResult } = useReportDesigner(); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); @@ -76,17 +76,55 @@ export function CanvasComponent({ component }: CanvasComponentProps) { } }); + // 표시할 값 결정 + const getDisplayValue = (): string => { + // 쿼리와 필드가 연결되어 있으면 실제 데이터 조회 + if (component.queryId && component.fieldName) { + const queryResult = getQueryResult(component.queryId); + + // 실행 결과가 있으면 첫 번째 행의 해당 필드 값 표시 + if (queryResult && queryResult.rows.length > 0) { + const firstRow = queryResult.rows[0]; + const value = firstRow[component.fieldName]; + + // 값이 있으면 문자열로 변환하여 반환 + if (value !== null && value !== undefined) { + return String(value); + } + } + + // 실행 결과가 없거나 값이 없으면 필드명 표시 + return `{${component.fieldName}}`; + } + + // 기본값이 있으면 기본값 표시 + if (component.defaultValue) { + return component.defaultValue; + } + + // 둘 다 없으면 타입에 따라 기본 텍스트 + return component.type === "text" ? "텍스트 입력" : "레이블 텍스트"; + }; + // 컴포넌트 타입별 렌더링 const renderContent = () => { + const displayValue = getDisplayValue(); + const hasBinding = component.queryId && component.fieldName; + switch (component.type) { case "text": return (
-
텍스트 필드
+
+ 텍스트 필드 + {hasBinding && ● 연결됨} +
e.stopPropagation()} />
@@ -95,12 +133,61 @@ export function CanvasComponent({ component }: CanvasComponentProps) { case "label": return (
-
레이블
-
레이블 텍스트
+
+ 레이블 + {hasBinding && ● 연결됨} +
+
{displayValue}
); case "table": + // 테이블은 쿼리 결과의 모든 행과 필드를 표시 + if (component.queryId) { + const queryResult = getQueryResult(component.queryId); + + if (queryResult && queryResult.rows.length > 0) { + return ( +
+
+ 테이블 (디테일 데이터) + ● 연결됨 +
+ + + + {queryResult.fields.map((field) => ( + + ))} + + + + {queryResult.rows.slice(0, 3).map((row, idx) => ( + + {queryResult.fields.map((field) => ( + + ))} + + ))} + {queryResult.rows.length > 3 && ( + + + + )} + +
+ {field} +
+ {String(row[field] ?? "")} +
+ ... 외 {queryResult.rows.length - 3}건 +
+
+ ); + } + } + + // 기본 테이블 (데이터 없을 때) return (
테이블 (디테일 데이터)
@@ -118,11 +205,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) { 10 50,000 - - 품목2 - 5 - 30,000 -
diff --git a/frontend/components/report/designer/QueryManager.tsx b/frontend/components/report/designer/QueryManager.tsx new file mode 100644 index 00000000..d4b3e2a4 --- /dev/null +++ b/frontend/components/report/designer/QueryManager.tsx @@ -0,0 +1,354 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Plus, Trash2, Play, AlertCircle, Database } from "lucide-react"; +import { useReportDesigner, ReportQuery } from "@/contexts/ReportDesignerContext"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { reportApi } from "@/lib/api/reportApi"; +import { useToast } from "@/hooks/use-toast"; + +export function QueryManager() { + const { queries, setQueries, reportId, setQueryResult, getQueryResult } = useReportDesigner(); + const [selectedQueryId, setSelectedQueryId] = useState(null); + const [isTestRunning, setIsTestRunning] = useState(false); + const [parameterValues, setParameterValues] = useState>({}); + const [parameterTypes, setParameterTypes] = useState>({}); + const { toast } = useToast(); + + const selectedQuery = queries.find((q) => q.id === selectedQueryId); + const testResult = selectedQuery ? getQueryResult(selectedQuery.id) : null; + + // 파라미터 감지 ($1, $2 등, 단 작은따옴표 안은 제외) + const detectParameters = (sql: string): string[] => { + // 작은따옴표 안의 내용을 제거 + const withoutStrings = sql.replace(/'[^']*'/g, ""); + + // $숫자 패턴 찾기 + const matches = withoutStrings.match(/\$\d+/g); + if (!matches) return []; + + // 중복 제거하되 등장 순서 유지 + const seen = new Set(); + const result: string[] = []; + for (const match of matches) { + if (!seen.has(match)) { + seen.add(match); + result.push(match); + } + } + return result; + }; + + // 새 쿼리 추가 + const handleAddQuery = () => { + const newQuery: ReportQuery = { + id: `query_${Date.now()}`, + name: `쿼리 ${queries.length + 1}`, + type: "MASTER", + sqlQuery: "", + parameters: [], + }; + setQueries([...queries, newQuery]); + setSelectedQueryId(newQuery.id); + }; + + // 쿼리 삭제 + const handleDeleteQuery = (queryId: string) => { + setQueries(queries.filter((q) => q.id !== queryId)); + if (selectedQueryId === queryId) { + setSelectedQueryId(null); + setParameterValues({}); + setParameterTypes({}); + setTestResult(null); + } + }; + + // 쿼리 선택 변경 + const handleSelectQuery = (queryId: string) => { + setSelectedQueryId(queryId); + setParameterValues({}); + setParameterTypes({}); + setTestResult(null); + }; + + // 파라미터 값이 모두 입력되었는지 확인 + const isAllParametersFilled = (): boolean => { + if (!selectedQuery || selectedQuery.parameters.length === 0) { + return true; + } + return selectedQuery.parameters.every((param) => { + const value = parameterValues[param]; + return value !== undefined && value.trim() !== ""; + }); + }; + + // 쿼리 업데이트 + const handleUpdateQuery = (queryId: string, updates: Partial) => { + setQueries( + queries.map((q) => { + if (q.id === queryId) { + const updated = { ...q, ...updates }; + // SQL이 변경되면 파라미터 재감지 + if (updates.sqlQuery !== undefined) { + updated.parameters = detectParameters(updated.sqlQuery); + } + return updated; + } + return q; + }), + ); + }; + + // 쿼리 테스트 실행 + const handleTestQuery = async () => { + if (!selectedQuery) { + toast({ + title: "알림", + description: "쿼리를 선택해주세요.", + variant: "destructive", + }); + return; + } + + setIsTestRunning(true); + try { + // new 리포트는 임시 ID 사용하고 SQL 쿼리 직접 전달 + const testReportId = reportId === "new" ? "TEMP_TEST" : reportId; + const sqlQuery = reportId === "new" ? selectedQuery.sqlQuery : undefined; + + // 실제 API 호출 + const response = await reportApi.executeQuery(testReportId, selectedQuery.id, parameterValues, sqlQuery); + + if (response.success && response.data) { + // Context에 실행 결과 저장 + setQueryResult(selectedQuery.id, response.data.fields, response.data.rows); + + toast({ + title: "성공", + description: `${response.data.rows.length}건의 데이터가 조회되었습니다.`, + }); + } + } catch (error: any) { + toast({ + title: "오류", + description: error.response?.data?.message || "쿼리 실행에 실패했습니다.", + variant: "destructive", + }); + } finally { + setIsTestRunning(false); + } + }; + + return ( + +
+ {/* 헤더 */} +
+

쿼리 관리

+ +
+ + {/* 안내 메시지 */} + + + + 마스터 쿼리는 1건의 데이터를 가져오고, + 디테일 쿼리는 여러 건을 반복 표시합니다. + + + + {/* 쿼리 목록 */} +
+ {queries.map((query) => ( + handleSelectQuery(query.id)} + > + +
+
+ {query.name} + + {query.type} + +
+ +
+
+ {query.parameters.length > 0 && ( + +
+ {query.parameters.map((param) => ( + + {param} + + ))} +
+
+ )} +
+ ))} +
+ + {/* 선택된 쿼리 편집 */} + {selectedQuery && ( + + + 쿼리 편집 + + + {/* 쿼리 이름 */} +
+ + handleUpdateQuery(selectedQuery.id, { name: e.target.value })} + placeholder="쿼리 이름" + className="h-8" + /> +
+ + {/* 쿼리 타입 */} +
+ + +
+ + {/* SQL 쿼리 */} +
+