ERP-node/frontend/components/report/designer/QueryManager.tsx

428 lines
16 KiB
TypeScript

"use client";
import { useState, useEffect } 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, Link2 } 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";
import type { ExternalConnection } from "@/types/report";
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 [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;
// 외부 DB 연결 목록 조회
useEffect(() => {
const fetchConnections = async () => {
setIsLoadingConnections(true);
try {
const response = await reportApi.getExternalConnections();
if (response.success && response.data) {
setExternalConnections(response.data);
}
} catch (error) {
console.error("외부 DB 연결 목록 조회 실패:", error);
} finally {
setIsLoadingConnections(false);
}
};
fetchConnections();
}, []);
// 파라미터 감지 ($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: [],
externalConnectionId: null, // 기본값: 내부 DB
};
setQueries([...queries, newQuery]);
setSelectedQueryId(newQuery.id);
};
// 쿼리 삭제
const handleDeleteQuery = (queryId: string) => {
setQueries(queries.filter((q) => q.id !== queryId));
if (selectedQueryId === queryId) {
setSelectedQueryId(null);
setParameterValues({});
setParameterTypes({});
}
};
// 쿼리 선택 변경
const handleSelectQuery = (queryId: string) => {
setSelectedQueryId(queryId);
setParameterValues({});
setParameterTypes({});
};
// 파라미터 값이 모두 입력되었는지 확인
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;
const externalConnectionId = (selectedQuery as any).externalConnectionId || null;
// 실제 API 호출
const response = await reportApi.executeQuery(
testReportId,
selectedQuery.id,
parameterValues,
sqlQuery,
externalConnectionId,
);
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>
{/* 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">
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
DB (PostgreSQL)
</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()}>
<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>
{/* 파라미터 입력 */}
{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>
);
}