ERP-node/frontend/components/admin/dashboard/QueryEditor.tsx

313 lines
11 KiB
TypeScript

"use client";
import React, { useState, useCallback } from "react";
import { ChartDataSource, QueryResult } from "./types";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
import { dashboardApi } from "@/lib/api/dashboard";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Play, Loader2, Database, Code, ChevronDown, ChevronRight } from "lucide-react";
interface QueryEditorProps {
dataSource?: ChartDataSource;
onDataSourceChange: (dataSource: ChartDataSource) => void;
onQueryTest?: (result: QueryResult) => void;
}
/**
* SQL 쿼리 에디터 컴포넌트
* - SQL 쿼리 작성 및 편집
* - 쿼리 실행 및 결과 미리보기
* - 현재 DB / 외부 DB 분기 처리
*/
export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: QueryEditorProps) {
const [query, setQuery] = useState(dataSource?.query || "");
const [isExecuting, setIsExecuting] = useState(false);
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [sampleQueryOpen, setSampleQueryOpen] = useState(false);
// dataSource.query가 변경되면 query state 업데이트 (저장된 쿼리 불러오기)
React.useEffect(() => {
if (dataSource?.query) {
setQuery(dataSource.query);
}
}, [dataSource?.query]);
// 쿼리 실행
const executeQuery = useCallback(async () => {
// console.log("🚀 executeQuery 호출됨!");
// console.log("📝 현재 쿼리:", query);
// console.log("✅ query.trim():", query.trim());
if (!query.trim()) {
setError("쿼리를 입력해주세요.");
return;
}
// 외부 DB인 경우 커넥션 ID 확인
if (dataSource?.connectionType === "external" && !dataSource?.externalConnectionId) {
setError("외부 DB 커넥션을 선택해주세요.");
// console.log("❌ 쿼리가 비어있음!");
return;
}
setIsExecuting(true);
setError(null);
// console.log("🔄 쿼리 실행 시작...");
try {
let apiResult: { columns: string[]; rows: any[]; rowCount: number };
// 현재 DB vs 외부 DB 분기
if (dataSource?.connectionType === "external" && dataSource?.externalConnectionId) {
// 외부 DB 쿼리 실행
const result = await ExternalDbConnectionAPI.executeQuery(
parseInt(dataSource.externalConnectionId),
query.trim(),
);
if (!result.success) {
throw new Error(result.message || "외부 DB 쿼리 실행에 실패했습니다.");
}
// ExternalDbConnectionAPI의 응답을 통일된 형식으로 변환
apiResult = {
columns: result.data?.[0] ? Object.keys(result.data[0]) : [],
rows: result.data || [],
rowCount: result.data?.length || 0,
};
} else {
// 현재 DB 쿼리 실행
apiResult = await dashboardApi.executeQuery(query.trim());
}
// 결과를 QueryResult 형식으로 변환
const result: QueryResult = {
columns: apiResult.columns,
rows: apiResult.rows,
totalRows: apiResult.rowCount,
executionTime: 0,
};
setQueryResult(result);
onQueryTest?.(result);
// 데이터 소스 업데이트
onDataSourceChange({
...dataSource,
type: "database",
query: query.trim(),
lastExecuted: new Date().toISOString(),
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "쿼리 실행 중 오류가 발생했습니다.";
setError(errorMessage);
} finally {
setIsExecuting(false);
}
}, [query, dataSource, onDataSourceChange, onQueryTest]);
// 샘플 쿼리 삽입
const insertSampleQuery = useCallback((sampleType: string) => {
const samples = {
users: `SELECT
dept_name as 부서명,
COUNT(*) as 회원수
FROM user_info
WHERE dept_name IS NOT NULL
GROUP BY dept_name
ORDER BY 회원수 DESC`,
dept: `SELECT
dept_code as 부서코드,
dept_name as 부서명,
location_name as 위치,
TO_CHAR(regdate, 'YYYY-MM-DD') as 등록일
FROM dept_info
ORDER BY dept_code`,
usersByDate: `SELECT
DATE_TRUNC('month', regdate)::date as 월,
COUNT(*) as 신규사용자수
FROM user_info
WHERE regdate >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY DATE_TRUNC('month', regdate)
ORDER BY 월`,
usersByPosition: `SELECT
position_name as 직급,
COUNT(*) as 인원수
FROM user_info
WHERE position_name IS NOT NULL
GROUP BY position_name
ORDER BY 인원수 DESC`,
deptHierarchy: `SELECT
COALESCE(parent_dept_code, '최상위') as 상위부서코드,
COUNT(*) as 하위부서수
FROM dept_info
GROUP BY parent_dept_code
ORDER BY 하위부서수 DESC`,
};
setQuery(samples[sampleType as keyof typeof samples] || "");
}, []);
return (
<div className="space-y-3">
{/* 쿼리 에디터 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Database className="text-primary h-3.5 w-3.5" />
<h4 className="text-foreground text-xs font-semibold">SQL </h4>
</div>
<Button onClick={executeQuery} disabled={isExecuting || !query.trim()} size="sm" className="h-7 text-xs">
{isExecuting ? (
<>
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
</>
) : (
<>
<Play className="mr-1.5 h-3 w-3" />
</>
)}
</Button>
</div>
{/* 샘플 쿼리 아코디언 */}
<Collapsible open={sampleQueryOpen} onOpenChange={setSampleQueryOpen}>
<CollapsibleTrigger className="border-border bg-muted text-foreground hover:bg-muted flex w-full items-center gap-1.5 rounded border px-2 py-1.5 text-xs font-medium transition-colors">
{sampleQueryOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
</CollapsibleTrigger>
<CollapsibleContent className="mt-2">
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => insertSampleQuery("users")}
className="border-border bg-background hover:bg-muted flex items-center gap-1 rounded border px-2 py-1 text-[11px] transition-colors"
>
<Code className="h-3 w-3" />
</button>
<button
onClick={() => insertSampleQuery("dept")}
className="border-border bg-background hover:bg-muted flex items-center gap-1 rounded border px-2 py-1 text-[11px] transition-colors"
>
<Code className="h-3 w-3" />
</button>
<button
onClick={() => insertSampleQuery("usersByDate")}
className="border-border bg-background hover:bg-muted rounded border px-2 py-1 text-[11px] transition-colors"
>
</button>
<button
onClick={() => insertSampleQuery("usersByPosition")}
className="border-border bg-background hover:bg-muted rounded border px-2 py-1 text-[11px] transition-colors"
>
</button>
<button
onClick={() => insertSampleQuery("deptHierarchy")}
className="border-border bg-background hover:bg-muted rounded border px-2 py-1 text-[11px] transition-colors"
>
</button>
</div>
</CollapsibleContent>
</Collapsible>
{/* SQL 쿼리 입력 영역 */}
<div className="space-y-1.5">
<Label className="text-xs">SQL </Label>
<div className="relative">
<Textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
// 모든 키보드 이벤트를 textarea 내부에서만 처리
e.stopPropagation();
}}
placeholder="SELECT * FROM your_table WHERE condition = 'value';"
className="h-32 resize-none font-mono text-[11px]"
/>
</div>
</div>
{/* 오류 메시지 */}
{error && (
<Alert variant="destructive" className="py-2">
<AlertDescription>
<div className="text-xs font-medium"></div>
<div className="mt-0.5 text-xs">{error}</div>
</AlertDescription>
</Alert>
)}
{/* 쿼리 결과 미리보기 */}
{queryResult && (
<Card>
<div className="border-border bg-muted border-b px-2 py-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-foreground text-xs font-medium"> </span>
<Badge variant="secondary" className="h-4 text-[10px]">
{queryResult.rows.length}
</Badge>
</div>
<span className="text-muted-foreground text-[10px]"> : {queryResult.executionTime}ms</span>
</div>
</div>
<div className="p-2">
{queryResult.rows.length > 0 ? (
<div className="max-h-48 overflow-auto">
<Table>
<TableHeader>
<TableRow>
{queryResult.columns.map((col, idx) => (
<TableHead key={idx} className="h-7 text-[11px]">
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{queryResult.rows.slice(0, 10).map((row, idx) => (
<TableRow key={idx}>
{queryResult.columns.map((col, colIdx) => (
<TableCell key={colIdx} className="py-1 text-[11px]">
{String(row[col] ?? "")}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
{queryResult.rows.length > 10 && (
<div className="text-muted-foreground mt-2 text-center text-[10px]">
... {queryResult.rows.length - 10} ( 10 )
</div>
)}
</div>
) : (
<div className="text-muted-foreground py-6 text-center text-xs"> .</div>
)}
</div>
</Card>
)}
</div>
);
}