쿼리 아코디언 방식으로 정리

This commit is contained in:
dohyeons 2025-10-02 14:58:22 +09:00
parent 4e1e5b0d51
commit 734e78c2da
3 changed files with 271 additions and 272 deletions

View File

@ -2,7 +2,6 @@
import { useState, useEffect, useMemo } 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";
@ -15,6 +14,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import type { ExternalConnection } from "@/types/report";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
// SQL 쿼리 안전성 검증 함수 (컴포넌트 외부에 선언)
const validateQuerySafety = (sql: string): { isValid: boolean; errorMessage: string | null } => {
@ -77,22 +77,15 @@ const validateQuerySafety = (sql: string): { isValid: boolean; errorMessage: str
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 [isTestRunning, setIsTestRunning] = useState<Record<string, boolean>>({});
const [parameterValues, setParameterValues] = useState<Record<string, Record<string, string>>>({});
const [parameterTypes, setParameterTypes] = useState<Record<string, Record<string, string>>>({});
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
const [isLoadingConnections, setIsLoadingConnections] = useState(false);
const { toast } = useToast();
const selectedQuery = queries.find((q) => q.id === selectedQueryId);
const testResult = selectedQuery ? getQueryResult(selectedQuery.id) : null;
// 선택된 쿼리의 안전성 검증 결과
const queryValidation = useMemo(
() => (selectedQuery ? validateQuerySafety(selectedQuery.sqlQuery) : { isValid: false, errorMessage: null }),
[selectedQuery],
);
// 각 쿼리의 안전성 검증 결과
const getQueryValidation = (query: ReportQuery) => validateQuerySafety(query.sqlQuery);
// 외부 DB 연결 목록 조회
useEffect(() => {
@ -142,36 +135,35 @@ export function QueryManager() {
type: "MASTER",
sqlQuery: "",
parameters: [],
externalConnectionId: null, // 기본값: 내부 DB
externalConnectionId: null,
};
setQueries([...queries, newQuery]);
setSelectedQueryId(newQuery.id);
};
// 쿼리 삭제
const handleDeleteQuery = (queryId: string) => {
const handleDeleteQuery = (queryId: string, e: React.MouseEvent) => {
e.stopPropagation();
setQueries(queries.filter((q) => q.id !== queryId));
if (selectedQueryId === queryId) {
setSelectedQueryId(null);
setParameterValues({});
setParameterTypes({});
}
};
// 쿼리 선택 변경
const handleSelectQuery = (queryId: string) => {
setSelectedQueryId(queryId);
setParameterValues({});
setParameterTypes({});
// 해당 쿼리의 상태 정리
const newParameterValues = { ...parameterValues };
const newParameterTypes = { ...parameterTypes };
const newIsTestRunning = { ...isTestRunning };
delete newParameterValues[queryId];
delete newParameterTypes[queryId];
delete newIsTestRunning[queryId];
setParameterValues(newParameterValues);
setParameterTypes(newParameterTypes);
setIsTestRunning(newIsTestRunning);
};
// 파라미터 값이 모두 입력되었는지 확인
const isAllParametersFilled = (): boolean => {
if (!selectedQuery || selectedQuery.parameters.length === 0) {
const isAllParametersFilled = (query: ReportQuery): boolean => {
if (!query || query.parameters.length === 0) {
return true;
}
return selectedQuery.parameters.every((param) => {
const value = parameterValues[param];
const queryParams = parameterValues[query.id] || {};
return query.parameters.every((param) => {
const value = queryParams[param];
return value !== undefined && value.trim() !== "";
});
};
@ -194,18 +186,9 @@ export function QueryManager() {
};
// 쿼리 테스트 실행
const handleTestQuery = async () => {
if (!selectedQuery) {
toast({
title: "알림",
description: "쿼리를 선택해주세요.",
variant: "destructive",
});
return;
}
const handleTestQuery = async (query: ReportQuery) => {
// SQL 쿼리 안전성 검증
const validation = validateQuerySafety(selectedQuery.sqlQuery);
const validation = validateQuerySafety(query.sqlQuery);
if (!validation.isValid) {
toast({
title: "쿼리 검증 실패",
@ -215,25 +198,23 @@ export function QueryManager() {
return;
}
setIsTestRunning(true);
setIsTestRunning({ ...isTestRunning, [query.id]: true });
try {
// new 리포트는 임시 ID 사용하고 SQL 쿼리 직접 전달
const testReportId = reportId === "new" ? "TEMP_TEST" : reportId;
const sqlQuery = reportId === "new" ? selectedQuery.sqlQuery : undefined;
const externalConnectionId = (selectedQuery as any).externalConnectionId || null;
const sqlQuery = reportId === "new" ? query.sqlQuery : undefined;
const externalConnectionId = (query as any).externalConnectionId || null;
const queryParams = parameterValues[query.id] || {};
// 실제 API 호출
const response = await reportApi.executeQuery(
testReportId,
selectedQuery.id,
parameterValues,
query.id,
queryParams,
sqlQuery,
externalConnectionId,
);
if (response.success && response.data) {
// Context에 실행 결과 저장
setQueryResult(selectedQuery.id, response.data.fields, response.data.rows);
setQueryResult(query.id, response.data.fields, response.data.rows);
toast({
title: "성공",
@ -247,7 +228,7 @@ export function QueryManager() {
variant: "destructive",
});
} finally {
setIsTestRunning(false);
setIsTestRunning({ ...isTestRunning, [query.id]: false });
}
};
@ -272,230 +253,215 @@ export function QueryManager() {
</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>
{/* 아코디언 목록 */}
{queries.length > 0 ? (
<Accordion type="single" collapsible>
{queries.map((query) => {
const testResult = getQueryResult(query.id);
const queryValidation = getQueryValidation(query);
const queryParams = parameterValues[query.id] || {};
const queryParamTypes = parameterTypes[query.id] || {};
{/* 선택된 쿼리 편집 */}
{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>
{/* DB 연결 선택 */}
<div className="space-y-2">
<Label className="flex items-center gap-2 text-xs">
<Link2 className="h-3 w-3" />
DB
</Label>
<Select
value={(selectedQuery as any).externalConnectionId?.toString() || "internal"}
onValueChange={(value) =>
handleUpdateQuery(selectedQuery.id, {
externalConnectionId: value === "internal" ? null : parseInt(value),
} as any)
}
disabled={isLoadingConnections}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal">
return (
<AccordionItem key={query.id} value={query.id} className="border-b border-gray-200">
<AccordionTrigger className="px-0 py-2.5 hover:no-underline">
<div className="flex w-full items-center justify-between pr-2">
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
DB (PostgreSQL)
<span className="text-sm font-medium">{query.name}</span>
<Badge variant={query.type === "MASTER" ? "default" : "secondary"} className="text-xs">
{query.type}
</Badge>
</div>
</SelectItem>
{externalConnections.length > 0 && (
<>
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500"> DB</div>
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDeleteQuery(query.id, e)}
className="h-7 w-7 p-0"
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-4 pt-1 pr-0 pb-3 pl-0">
{/* 쿼리 이름 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
value={query.name}
onChange={(e) => handleUpdateQuery(query.id, { name: e.target.value })}
placeholder="쿼리 이름"
className="h-8"
/>
</div>
{/* 쿼리 타입 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={query.type}
onValueChange={(value: "MASTER" | "DETAIL") => handleUpdateQuery(query.id, { type: value })}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MASTER"> (1)</SelectItem>
<SelectItem value="DETAIL"> ()</SelectItem>
</SelectContent>
</Select>
</div>
{/* DB 연결 선택 */}
<div className="space-y-2">
<Label className="flex items-center gap-2 text-xs">
<Link2 className="h-3 w-3" />
DB
</Label>
<Select
value={(query as any).externalConnectionId?.toString() || "internal"}
onValueChange={(value) =>
handleUpdateQuery(query.id, {
externalConnectionId: value === "internal" ? null : parseInt(value),
} as any)
}
disabled={isLoadingConnections}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal">
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
{conn.connection_name}
<Badge variant="outline" className="text-xs">
{conn.db_type.toUpperCase()}
</Badge>
DB (PostgreSQL)
</div>
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
{externalConnections.length > 0 && (
<>
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500"> DB</div>
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
{conn.connection_name}
<Badge variant="outline" className="text-xs">
{conn.db_type.toUpperCase()}
</Badge>
</div>
</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>
{/* SQL 쿼리 */}
<div className="space-y-2">
<Textarea
value={query.sqlQuery}
onChange={(e) => handleUpdateQuery(query.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,
})
}
/>
{/* 파라미터 입력 */}
{query.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>
</div>
)}
<div className="space-y-2">
{query.parameters.map((param) => {
const paramType = queryParamTypes[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,
[query.id]: {
...queryParamTypes,
[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={queryParams[param] || ""}
onChange={(e) =>
setParameterValues({
...parameterValues,
[query.id]: {
...queryParams,
[param]: e.target.value,
},
})
}
/>
</div>
);
})}
</div>
</div>
)}
{/* SQL 검증 경고 메시지 */}
{!queryValidation.isValid && queryValidation.errorMessage && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">{queryValidation.errorMessage}</AlertDescription>
</Alert>
)}
{/* SQL 검증 경고 메시지 */}
{!queryValidation.isValid && queryValidation.errorMessage && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">{queryValidation.errorMessage}</AlertDescription>
</Alert>
)}
{/* 테스트 실행 */}
<Button
size="sm"
variant="default"
className="w-full bg-red-500 hover:bg-red-600"
onClick={handleTestQuery}
disabled={!queryValidation.isValid || isTestRunning || !isAllParametersFilled()}
>
<Play className="mr-2 h-4 w-4" />
{isTestRunning ? "실행 중..." : "실행"}
</Button>
{/* 테스트 실행 */}
<Button
size="sm"
variant="default"
className="w-full bg-red-500 hover:bg-red-600"
onClick={() => handleTestQuery(query)}
disabled={!queryValidation.isValid || isTestRunning[query.id] || !isAllParametersFilled(query)}
>
<Play className="mr-2 h-4 w-4" />
{isTestRunning[query.id] ? "실행 중..." : "실행"}
</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 && (
{/* 결과 필드 */}
{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>
)}
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
) : (
<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">

View File

@ -164,7 +164,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
content = `
<table style="width: 100%; border-collapse: ${component.showBorder !== false ? "collapse" : "separate"}; font-size: 12px;">
<thead>
<thead style="display: table-header-group; break-inside: avoid; break-after: avoid;">
<tr style="background-color: ${component.headerBackgroundColor || "#f3f4f6"}; color: ${component.headerTextColor || "#111827"};">
${columns.map((col: { header: string; align?: string; width?: number }) => `<th style="border: ${component.showBorder !== false ? "1px solid #d1d5db" : "none"}; padding: 6px 8px; text-align: ${col.align || "left"}; width: ${col.width ? `${col.width}px` : "auto"}; font-weight: 600;">${col.header}</th>`).join("")}
</tr>

View File

@ -69,7 +69,7 @@ function Accordion({
return (
<AccordionContext.Provider value={contextValue}>
<div className={cn("space-y-2", className)} onClick={onClick} {...props}>
<div className={cn(className)} onClick={onClick} {...props}>
{children}
</div>
</AccordionContext.Provider>
@ -83,8 +83,33 @@ interface AccordionItemProps {
}
function AccordionItem({ value, className, children, ...props }: AccordionItemProps) {
const context = React.useContext(AccordionContext);
const handleClick = (e: React.MouseEvent) => {
if (!context?.onValueChange) return;
const target = e.target as HTMLElement;
if (target.closest('button[type="button"]') && !target.closest(".accordion-trigger")) {
return;
}
const isOpen =
context.type === "multiple"
? Array.isArray(context.value) && context.value.includes(value)
: context.value === value;
if (context.type === "multiple") {
const currentValue = Array.isArray(context.value) ? context.value : [];
const newValue = isOpen ? currentValue.filter((v) => v !== value) : [...currentValue, value];
context.onValueChange(newValue);
} else {
const newValue = isOpen && context.collapsible ? "" : value;
context.onValueChange(newValue);
}
};
return (
<div className={cn("rounded-md border", className)} data-value={value} {...props}>
<div className={cn("cursor-pointer", className)} data-value={value} onClick={handleClick} {...props}>
{children}
</div>
);
@ -124,7 +149,7 @@ function AccordionTrigger({ className, children, ...props }: AccordionTriggerPro
return (
<button
className={cn(
"flex w-full items-center justify-between p-4 text-left font-medium transition-colors hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none",
"accordion-trigger flex w-full cursor-pointer items-center justify-between p-4 text-left font-medium transition-colors focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none",
className,
)}
onClick={handleClick}
@ -145,6 +170,7 @@ interface AccordionContentProps {
function AccordionContent({ className, children, ...props }: AccordionContentProps) {
const context = React.useContext(AccordionContext);
const parent = React.useContext(AccordionItemContext);
const contentRef = React.useRef<HTMLDivElement>(null);
if (!context || !parent) {
throw new Error("AccordionContent must be used within AccordionItem");
@ -155,11 +181,18 @@ function AccordionContent({ className, children, ...props }: AccordionContentPro
? Array.isArray(context.value) && context.value.includes(parent.value)
: context.value === parent.value;
if (!isOpen) return null;
return (
<div className={cn("px-4 pb-4 text-sm text-gray-600", className)} {...props}>
{children}
<div
ref={contentRef}
className={cn(
"overflow-hidden text-sm text-gray-600 transition-all duration-300 ease-in-out",
isOpen ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0",
className,
)}
onClick={(e) => e.stopPropagation()}
{...props}
>
<div className="cursor-default">{children}</div>
</div>
);
}