리포트 쿼리 실행 결과를 컴포넌트에 실시간 바인딩
This commit is contained in:
parent
6a221d3e7e
commit
579d4224d5
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ReportLayout>(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<ReportQuery>(queriesQuery, [reportId]);
|
||||
|
||||
return {
|
||||
report,
|
||||
layout,
|
||||
queries: queries || [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -306,7 +328,12 @@ export class ReportService {
|
|||
*/
|
||||
async deleteReport(reportId: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<string, any>,
|
||||
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<ReportQuery>(
|
||||
`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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 목록 조회
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-1 text-xs text-gray-500">텍스트 필드</div>
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>텍스트 필드</span>
|
||||
{hasBinding && <span className="text-blue-600">● 연결됨</span>}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded border px-2 py-1 text-sm"
|
||||
placeholder="텍스트 입력"
|
||||
placeholder={displayValue}
|
||||
value={displayValue}
|
||||
readOnly
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -95,12 +133,61 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
case "label":
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-1 text-xs text-gray-500">레이블</div>
|
||||
<div className="font-semibold">레이블 텍스트</div>
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>레이블</span>
|
||||
{hasBinding && <span className="text-blue-600">● 연결됨</span>}
|
||||
</div>
|
||||
<div className="font-semibold">{displayValue}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "table":
|
||||
// 테이블은 쿼리 결과의 모든 행과 필드를 표시
|
||||
if (component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
|
||||
if (queryResult && queryResult.rows.length > 0) {
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>테이블 (디테일 데이터)</span>
|
||||
<span className="text-blue-600">● 연결됨</span>
|
||||
</div>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
{queryResult.fields.map((field) => (
|
||||
<th key={field} className="border p-1">
|
||||
{field}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{queryResult.rows.slice(0, 3).map((row, idx) => (
|
||||
<tr key={idx}>
|
||||
{queryResult.fields.map((field) => (
|
||||
<td key={field} className="border p-1">
|
||||
{String(row[field] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{queryResult.rows.length > 3 && (
|
||||
<tr>
|
||||
<td colSpan={queryResult.fields.length} className="border p-1 text-center text-gray-400">
|
||||
... 외 {queryResult.rows.length - 3}건
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 테이블 (데이터 없을 때)
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<div className="mb-1 text-xs text-gray-500">테이블 (디테일 데이터)</div>
|
||||
|
|
@ -118,11 +205,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
<td className="border p-1">10</td>
|
||||
<td className="border p-1">50,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border p-1">품목2</td>
|
||||
<td className="border p-1">5</td>
|
||||
<td className="border p-1">30,000</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [isTestRunning, setIsTestRunning] = useState(false);
|
||||
const [parameterValues, setParameterValues] = useState<Record<string, string>>({});
|
||||
const [parameterTypes, setParameterTypes] = useState<Record<string, string>>({});
|
||||
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<string>();
|
||||
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<ReportQuery>) => {
|
||||
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 (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">쿼리 관리</h3>
|
||||
<Button size="sm" onClick={handleAddQuery}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
<strong>마스터 쿼리</strong>는 1건의 데이터를 가져오고,
|
||||
<strong>디테일 쿼리</strong>는 여러 건을 반복 표시합니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* 쿼리 목록 */}
|
||||
<div className="space-y-2">
|
||||
{queries.map((query) => (
|
||||
<Card
|
||||
key={query.id}
|
||||
className={`cursor-pointer transition-colors ${
|
||||
selectedQueryId === query.id ? "border-blue-500 bg-blue-50" : "hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => handleSelectQuery(query.id)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-sm">{query.name}</CardTitle>
|
||||
<Badge variant={query.type === "MASTER" ? "default" : "secondary"} className="text-xs">
|
||||
{query.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteQuery(query.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{query.parameters.length > 0 && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{query.parameters.map((param) => (
|
||||
<Badge key={param} variant="outline" className="text-xs">
|
||||
{param}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 선택된 쿼리 편집 */}
|
||||
{selectedQuery && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">쿼리 편집</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 쿼리 이름 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">쿼리 이름</Label>
|
||||
<Input
|
||||
value={selectedQuery.name}
|
||||
onChange={(e) => handleUpdateQuery(selectedQuery.id, { name: e.target.value })}
|
||||
placeholder="쿼리 이름"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">쿼리 타입</Label>
|
||||
<Select
|
||||
value={selectedQuery.type}
|
||||
onValueChange={(value: "MASTER" | "DETAIL") => handleUpdateQuery(selectedQuery.id, { type: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MASTER">마스터 (1건)</SelectItem>
|
||||
<SelectItem value="DETAIL">디테일 (반복)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* SQL 쿼리 */}
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={selectedQuery.sqlQuery}
|
||||
onChange={(e) => handleUpdateQuery(selectedQuery.id, { sqlQuery: e.target.value })}
|
||||
placeholder="SELECT * FROM orders WHERE order_id = $1"
|
||||
className="min-h-[150px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 파라미터 입력 */}
|
||||
{selectedQuery.parameters.length > 0 && (
|
||||
<div className="space-y-3 rounded-md border border-yellow-200 bg-yellow-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600" />
|
||||
<Label className="text-xs font-semibold text-yellow-800">파라미터</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{selectedQuery.parameters.map((param) => {
|
||||
const paramType = parameterTypes[param] || "text";
|
||||
return (
|
||||
<div key={param} className="flex items-center gap-2">
|
||||
<Label className="w-12 text-xs font-semibold">{param}</Label>
|
||||
<Select
|
||||
value={paramType}
|
||||
onValueChange={(value) =>
|
||||
setParameterTypes({
|
||||
...parameterTypes,
|
||||
[param]: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">텍스트</SelectItem>
|
||||
<SelectItem value="number">숫자</SelectItem>
|
||||
<SelectItem value="date">날짜</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type={paramType === "number" ? "number" : paramType === "date" ? "date" : "text"}
|
||||
placeholder="값"
|
||||
className="h-8 flex-1"
|
||||
value={parameterValues[param] || ""}
|
||||
onChange={(e) =>
|
||||
setParameterValues({
|
||||
...parameterValues,
|
||||
[param]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테스트 실행 */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="w-full bg-red-500 hover:bg-red-600"
|
||||
onClick={handleTestQuery}
|
||||
disabled={!selectedQuery.sqlQuery || isTestRunning || !isAllParametersFilled()}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{isTestRunning ? "실행 중..." : "실행"}
|
||||
</Button>
|
||||
|
||||
{/* 결과 필드 */}
|
||||
{testResult && (
|
||||
<div className="space-y-2 rounded-md border border-green-200 bg-green-50 p-3">
|
||||
<Label className="text-xs font-semibold text-green-800">결과 필드</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{testResult.fields.map((field) => (
|
||||
<Badge key={field} variant="default" className="bg-teal-500">
|
||||
{field}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-green-700">{testResult.rows.length}건의 데이터가 조회되었습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{queries.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Database className="mb-2 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-gray-500">
|
||||
쿼리를 추가하여 리포트에
|
||||
<br />
|
||||
데이터를 연결하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import { TemplatePalette } from "./TemplatePalette";
|
|||
|
||||
export function ReportDesignerLeftPanel() {
|
||||
return (
|
||||
<div className="w-60 border-r bg-white">
|
||||
<div className="w-80 border-r bg-white">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 템플릿 */}
|
||||
|
|
|
|||
|
|
@ -1,162 +1,308 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Trash2, Settings, Database, Link2 } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { QueryManager } from "./QueryManager";
|
||||
|
||||
export function ReportDesignerRightPanel() {
|
||||
const { selectedComponentId, components, updateComponent, removeComponent } = useReportDesigner();
|
||||
const context = useReportDesigner();
|
||||
const { selectedComponentId, components, updateComponent, removeComponent, queries } = context;
|
||||
const [activeTab, setActiveTab] = useState<string>("properties");
|
||||
|
||||
const selectedComponent = components.find((c) => c.id === selectedComponentId);
|
||||
|
||||
if (!selectedComponent) {
|
||||
return (
|
||||
<div className="w-80 border-l bg-white">
|
||||
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-gray-500">
|
||||
컴포넌트를 선택하면 속성을 편집할 수 있습니다
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 선택된 쿼리의 결과 필드 가져오기
|
||||
const getQueryFields = (queryId: string): string[] => {
|
||||
const result = context.getQueryResult(queryId);
|
||||
return result ? result.fields : [];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 border-l bg-white">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 컴포넌트 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">컴포넌트 속성</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeComponent(selectedComponent.id)}
|
||||
className="text-destructive hover:bg-destructive/10 h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">타입</Label>
|
||||
<div className="mt-1 text-sm font-medium capitalize">{selectedComponent.type}</div>
|
||||
</div>
|
||||
|
||||
{/* 위치 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">X</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={Math.round(selectedComponent.x)}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
x: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Y</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={Math.round(selectedComponent.y)}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
y: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 크기 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">너비</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={Math.round(selectedComponent.width)}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
width: parseInt(e.target.value) || 50,
|
||||
})
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">높이</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={Math.round(selectedComponent.height)}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
height: parseInt(e.target.value) || 30,
|
||||
})
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 글꼴 크기 */}
|
||||
<div>
|
||||
<Label className="text-xs">글꼴 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.fontSize || 13}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
fontSize: parseInt(e.target.value) || 13,
|
||||
})
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 글꼴 색상 */}
|
||||
<div>
|
||||
<Label className="text-xs">글꼴 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.fontColor || "#000000"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
fontColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 배경 색상 */}
|
||||
<div>
|
||||
<Label className="text-xs">배경 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.backgroundColor || "#ffffff"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
backgroundColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="w-[450px] border-l bg-white">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full">
|
||||
<div className="border-b p-2">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="properties" className="gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
속성
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="queries" className="gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
쿼리
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 속성 탭 */}
|
||||
<TabsContent value="properties" className="mt-0 h-[calc(100vh-120px)]">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{!selectedComponent ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-gray-500">
|
||||
컴포넌트를 선택하면 속성을 편집할 수 있습니다
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">컴포넌트 속성</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeComponent(selectedComponent.id)}
|
||||
className="text-destructive hover:bg-destructive/10 h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">타입</Label>
|
||||
<div className="mt-1 text-sm font-medium capitalize">{selectedComponent.type}</div>
|
||||
</div>
|
||||
|
||||
{/* 위치 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">X</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={Math.round(selectedComponent.x)}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
x: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Y</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={Math.round(selectedComponent.y)}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
y: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 크기 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">너비</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={Math.round(selectedComponent.width)}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
width: parseInt(e.target.value) || 50,
|
||||
})
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">높이</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={Math.round(selectedComponent.height)}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
height: parseInt(e.target.value) || 30,
|
||||
})
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 글꼴 크기 */}
|
||||
<div>
|
||||
<Label className="text-xs">글꼴 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.fontSize || 13}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
fontSize: parseInt(e.target.value) || 13,
|
||||
})
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 글꼴 색상 */}
|
||||
<div>
|
||||
<Label className="text-xs">글꼴 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.fontColor || "#000000"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
fontColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 배경 색상 */}
|
||||
<div>
|
||||
<Label className="text-xs">배경 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.backgroundColor || "#ffffff"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
backgroundColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */}
|
||||
{(selectedComponent.type === "text" ||
|
||||
selectedComponent.type === "label" ||
|
||||
selectedComponent.type === "table") && (
|
||||
<Card className="mt-4 border-blue-200 bg-blue-50">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4 text-blue-600" />
|
||||
<CardTitle className="text-sm text-blue-900">데이터 바인딩</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* 쿼리 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">쿼리</Label>
|
||||
<Select
|
||||
value={selectedComponent.queryId || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
queryId: value === "none" ? undefined : value,
|
||||
fieldName: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="쿼리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{queries.map((query) => (
|
||||
<SelectItem key={query.id} value={query.id}>
|
||||
{query.name} ({query.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 필드 선택 (텍스트/라벨만) */}
|
||||
{selectedComponent.queryId &&
|
||||
(selectedComponent.type === "text" || selectedComponent.type === "label") && (
|
||||
<div>
|
||||
<Label className="text-xs">필드</Label>
|
||||
<Select
|
||||
value={selectedComponent.fieldName || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
fieldName: value === "none" ? undefined : value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{getQueryFields(selectedComponent.queryId).length > 0 ? (
|
||||
getQueryFields(selectedComponent.queryId).map((field) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{field}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="no-result" disabled>
|
||||
쿼리 탭에서 실행 필요
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 안내 메시지 */}
|
||||
{selectedComponent.queryId && selectedComponent.type === "table" && (
|
||||
<div className="rounded-md bg-blue-100 p-2 text-xs text-blue-800">
|
||||
테이블은 선택한 쿼리의 모든 필드를 자동으로 표시합니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기본값 (텍스트/라벨만) */}
|
||||
{(selectedComponent.type === "text" || selectedComponent.type === "label") && (
|
||||
<div>
|
||||
<Label className="text-xs">기본값</Label>
|
||||
<Input
|
||||
value={selectedComponent.defaultValue || ""}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
defaultValue: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="데이터가 없을 때 표시할 값"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 포맷 (텍스트/라벨만) */}
|
||||
{(selectedComponent.type === "text" || selectedComponent.type === "label") && (
|
||||
<div>
|
||||
<Label className="text-xs">포맷</Label>
|
||||
<Input
|
||||
value={selectedComponent.format || ""}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
format: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="예: YYYY-MM-DD, #,###"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* 쿼리 탭 */}
|
||||
<TabsContent value="queries" className="mt-0 h-[calc(100vh-120px)]">
|
||||
<QueryManager />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,20 @@ import { ComponentConfig, ReportDetail, ReportLayout } from "@/types/report";
|
|||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export interface ReportQuery {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "MASTER" | "DETAIL";
|
||||
sqlQuery: string;
|
||||
parameters: string[];
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
queryId: string;
|
||||
fields: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
interface ReportDesignerContextType {
|
||||
reportId: string;
|
||||
reportDetail: ReportDetail | null;
|
||||
|
|
@ -14,6 +28,15 @@ interface ReportDesignerContextType {
|
|||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
|
||||
// 쿼리 관리
|
||||
queries: ReportQuery[];
|
||||
setQueries: (queries: ReportQuery[]) => void;
|
||||
|
||||
// 쿼리 실행 결과
|
||||
queryResults: QueryResult[];
|
||||
setQueryResult: (queryId: string, fields: string[], rows: Record<string, unknown>[]) => void;
|
||||
getQueryResult: (queryId: string) => QueryResult | null;
|
||||
|
||||
// 컴포넌트 관리
|
||||
addComponent: (component: ComponentConfig) => void;
|
||||
updateComponent: (id: string, updates: Partial<ComponentConfig>) => void;
|
||||
|
|
@ -43,6 +66,8 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
const [reportDetail, setReportDetail] = useState<ReportDetail | null>(null);
|
||||
const [layout, setLayout] = useState<ReportLayout | null>(null);
|
||||
const [components, setComponents] = useState<ComponentConfig[]>([]);
|
||||
const [queries, setQueries] = useState<ReportQuery[]>([]);
|
||||
const [queryResults, setQueryResults] = useState<QueryResult[]>([]);
|
||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
|
@ -69,10 +94,22 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
return;
|
||||
}
|
||||
|
||||
// 리포트 상세 조회
|
||||
// 리포트 상세 조회 (쿼리 포함)
|
||||
const detailResponse = await reportApi.getReportById(reportId);
|
||||
if (detailResponse.success && detailResponse.data) {
|
||||
setReportDetail(detailResponse.data);
|
||||
|
||||
// 쿼리 로드
|
||||
if (detailResponse.data.queries && detailResponse.data.queries.length > 0) {
|
||||
const loadedQueries = detailResponse.data.queries.map((q) => ({
|
||||
id: q.query_id,
|
||||
name: q.query_name,
|
||||
type: q.query_type,
|
||||
sqlQuery: q.sql_query,
|
||||
parameters: Array.isArray(q.parameters) ? q.parameters : [],
|
||||
}));
|
||||
setQueries(loadedQueries);
|
||||
}
|
||||
}
|
||||
|
||||
// 레이아웃 조회
|
||||
|
|
@ -92,14 +129,15 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
right: layoutData.margin_right,
|
||||
});
|
||||
}
|
||||
} catch (layoutError) {
|
||||
} catch {
|
||||
// 레이아웃이 없으면 기본값 사용
|
||||
console.log("레이아웃 없음, 기본값 사용");
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "리포트를 불러오는데 실패했습니다.";
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "리포트를 불러오는데 실패했습니다.",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -112,6 +150,25 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
loadLayout();
|
||||
}, [loadLayout]);
|
||||
|
||||
// 쿼리 결과 저장
|
||||
const setQueryResult = useCallback((queryId: string, fields: string[], rows: Record<string, unknown>[]) => {
|
||||
setQueryResults((prev) => {
|
||||
const existing = prev.find((r) => r.queryId === queryId);
|
||||
if (existing) {
|
||||
return prev.map((r) => (r.queryId === queryId ? { queryId, fields, rows } : r));
|
||||
}
|
||||
return [...prev, { queryId, fields, rows }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 쿼리 결과 조회
|
||||
const getQueryResult = useCallback(
|
||||
(queryId: string): QueryResult | null => {
|
||||
return queryResults.find((r) => r.queryId === queryId) || null;
|
||||
},
|
||||
[queryResults],
|
||||
);
|
||||
|
||||
// 컴포넌트 추가
|
||||
const addComponent = useCallback((component: ComponentConfig) => {
|
||||
setComponents((prev) => [...prev, component]);
|
||||
|
|
@ -184,7 +241,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
window.history.replaceState({}, "", `/admin/report/designer/${actualReportId}`);
|
||||
}
|
||||
|
||||
// 레이아웃 저장
|
||||
// 레이아웃 저장 (쿼리 포함)
|
||||
await reportApi.saveLayout(actualReportId, {
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
|
|
@ -194,6 +251,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
marginLeft: margins.left,
|
||||
marginRight: margins.right,
|
||||
components,
|
||||
queries,
|
||||
});
|
||||
|
||||
toast({
|
||||
|
|
@ -205,22 +263,28 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
if (reportId === "new") {
|
||||
await loadLayout();
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "저장에 실패했습니다.";
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "저장에 실패했습니다.",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [reportId, canvasWidth, canvasHeight, pageOrientation, margins, components, toast, loadLayout]);
|
||||
}, [reportId, canvasWidth, canvasHeight, pageOrientation, margins, components, queries, toast, loadLayout]);
|
||||
|
||||
const value: ReportDesignerContextType = {
|
||||
reportId,
|
||||
reportDetail,
|
||||
layout,
|
||||
components,
|
||||
queries,
|
||||
setQueries,
|
||||
queryResults,
|
||||
setQueryResult,
|
||||
getQueryResult,
|
||||
selectedComponentId,
|
||||
isLoading,
|
||||
isSaving,
|
||||
|
|
|
|||
|
|
@ -116,4 +116,16 @@ export const reportApi = {
|
|||
}>(`${BASE_URL}/templates/${templateId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 쿼리 실행
|
||||
executeQuery: async (reportId: string, queryId: string, parameters: Record<string, any>, sqlQuery?: string) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: {
|
||||
fields: string[];
|
||||
rows: any[];
|
||||
};
|
||||
}>(`${BASE_URL}/${reportId}/queries/${queryId}/execute`, { parameters, sqlQuery });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -55,6 +55,21 @@ 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[];
|
||||
display_order: number;
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
updated_at: string | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
// 컴포넌트 설정
|
||||
export interface ComponentConfig {
|
||||
id: string;
|
||||
|
|
@ -87,6 +102,7 @@ export interface ComponentConfig {
|
|||
export interface ReportDetail {
|
||||
report: ReportMaster;
|
||||
layout: ReportLayout | null;
|
||||
queries: ReportQuery[];
|
||||
}
|
||||
|
||||
// 리포트 목록 응답
|
||||
|
|
@ -137,6 +153,13 @@ export interface SaveLayoutRequest {
|
|||
marginLeft: number;
|
||||
marginRight: number;
|
||||
components: ComponentConfig[];
|
||||
queries?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: "MASTER" | "DETAIL";
|
||||
sqlQuery: string;
|
||||
parameters: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
// 템플릿 목록 응답
|
||||
|
|
|
|||
Loading…
Reference in New Issue