쿼리 아코디언 방식으로 정리
This commit is contained in:
parent
4e1e5b0d51
commit
734e78c2da
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue