리포트 쿼리 실행 결과를 컴포넌트에 실시간 바인딩

This commit is contained in:
dohyeons 2025-10-01 13:53:45 +09:00
parent 6a221d3e7e
commit 579d4224d5
11 changed files with 1062 additions and 168 deletions

View File

@ -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();

View File

@ -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;

View File

@ -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}`);
}
}
/**
* 릿
*/

View File

@ -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;
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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">
{/* 템플릿 */}

View File

@ -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>
);
}

View File

@ -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,

View File

@ -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;
},
};

View File

@ -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[];
}>;
}
// 템플릿 목록 응답