1112 lines
49 KiB
TypeScript
1112 lines
49 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* DELETE 액션 노드 속성 편집
|
||
*/
|
||
|
||
import { useEffect, useState, useCallback } from "react";
|
||
import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } from "lucide-react";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
import { cn } from "@/lib/utils";
|
||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||
import { tableTypeApi } from "@/lib/api/screen";
|
||
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||
import type { DeleteActionNodeData } from "@/types/node-editor";
|
||
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||
|
||
interface DeleteActionPropertiesProps {
|
||
nodeId: string;
|
||
data: DeleteActionNodeData;
|
||
}
|
||
|
||
// 소스 필드 타입
|
||
interface SourceField {
|
||
name: string;
|
||
label?: string;
|
||
}
|
||
|
||
const OPERATORS = [
|
||
{ value: "EQUALS", label: "=" },
|
||
{ value: "NOT_EQUALS", label: "≠" },
|
||
{ value: "GREATER_THAN", label: ">" },
|
||
{ value: "LESS_THAN", label: "<" },
|
||
{ value: "IN", label: "IN" },
|
||
{ value: "NOT_IN", label: "NOT IN" },
|
||
] as const;
|
||
|
||
export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) {
|
||
const { updateNode, getExternalConnectionsCache, nodes, edges } = useFlowEditorStore();
|
||
|
||
// 🔥 타겟 타입 상태
|
||
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
|
||
|
||
const [displayName, setDisplayName] = useState(data.displayName || `${data.targetTable} 삭제`);
|
||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
|
||
|
||
// 🆕 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||
const [sourceFields, setSourceFields] = useState<SourceField[]>([]);
|
||
const [sourceFieldsOpenState, setSourceFieldsOpenState] = useState<boolean[]>([]);
|
||
|
||
// 🔥 외부 DB 관련 상태
|
||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
|
||
const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState<number | undefined>(
|
||
data.externalConnectionId,
|
||
);
|
||
const [externalTables, setExternalTables] = useState<ExternalTable[]>([]);
|
||
const [externalTablesLoading, setExternalTablesLoading] = useState(false);
|
||
const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable);
|
||
const [externalColumns, setExternalColumns] = useState<ExternalColumn[]>([]);
|
||
const [externalColumnsLoading, setExternalColumnsLoading] = useState(false);
|
||
|
||
// 🔥 REST API 관련 상태 (DELETE는 요청 바디 없음)
|
||
const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || "");
|
||
const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none");
|
||
const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {});
|
||
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
|
||
|
||
// 🔥 내부 DB 테이블 관련 상태
|
||
const [tables, setTables] = useState<any[]>([]);
|
||
const [tablesLoading, setTablesLoading] = useState(false);
|
||
const [tablesOpen, setTablesOpen] = useState(false);
|
||
const [selectedTableLabel, setSelectedTableLabel] = useState(data.targetTable);
|
||
|
||
// 내부 DB 컬럼 관련 상태
|
||
interface ColumnInfo {
|
||
columnName: string;
|
||
columnLabel?: string;
|
||
dataType: string;
|
||
isNullable: boolean;
|
||
}
|
||
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
|
||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||
|
||
// Combobox 열림 상태 관리
|
||
const [fieldOpenState, setFieldOpenState] = useState<boolean[]>([]);
|
||
|
||
useEffect(() => {
|
||
setDisplayName(data.displayName || `${data.targetTable} 삭제`);
|
||
setTargetTable(data.targetTable);
|
||
setWhereConditions(data.whereConditions || []);
|
||
}, [data]);
|
||
|
||
// 🔥 내부 DB 테이블 목록 로딩
|
||
useEffect(() => {
|
||
if (targetType === "internal") {
|
||
loadTables();
|
||
}
|
||
}, [targetType]);
|
||
|
||
// 🔥 외부 커넥션 로딩
|
||
useEffect(() => {
|
||
if (targetType === "external") {
|
||
loadExternalConnections();
|
||
}
|
||
}, [targetType]);
|
||
|
||
// 🔥 외부 테이블 로딩
|
||
useEffect(() => {
|
||
if (targetType === "external" && selectedExternalConnectionId) {
|
||
loadExternalTables(selectedExternalConnectionId);
|
||
}
|
||
}, [targetType, selectedExternalConnectionId]);
|
||
|
||
// 🔥 외부 컬럼 로딩
|
||
useEffect(() => {
|
||
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
||
loadExternalColumns(selectedExternalConnectionId, externalTargetTable);
|
||
}
|
||
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
|
||
|
||
// 🔥 내부 DB 컬럼 로딩
|
||
useEffect(() => {
|
||
if (targetType === "internal" && targetTable) {
|
||
loadColumns(targetTable);
|
||
}
|
||
}, [targetType, targetTable]);
|
||
|
||
// whereConditions 변경 시 fieldOpenState 초기화
|
||
useEffect(() => {
|
||
setFieldOpenState(new Array(whereConditions.length).fill(false));
|
||
setSourceFieldsOpenState(new Array(whereConditions.length).fill(false));
|
||
}, [whereConditions.length]);
|
||
|
||
// 🆕 소스 필드 로딩 (연결된 입력 노드에서)
|
||
const loadSourceFields = useCallback(async () => {
|
||
// 현재 노드로 연결된 엣지 찾기
|
||
const incomingEdges = edges.filter((e) => e.target === nodeId);
|
||
console.log("🔍 DELETE 노드 연결 엣지:", incomingEdges);
|
||
|
||
if (incomingEdges.length === 0) {
|
||
console.log("⚠️ 연결된 소스 노드가 없습니다");
|
||
setSourceFields([]);
|
||
return;
|
||
}
|
||
|
||
const fields: SourceField[] = [];
|
||
const processedFields = new Set<string>();
|
||
|
||
for (const edge of incomingEdges) {
|
||
const sourceNode = nodes.find((n) => n.id === edge.source);
|
||
if (!sourceNode) continue;
|
||
|
||
console.log("🔗 소스 노드:", sourceNode.type, sourceNode.data);
|
||
|
||
// 소스 노드 타입에 따라 필드 추출
|
||
if (sourceNode.type === "trigger" && sourceNode.data.tableName) {
|
||
// 트리거 노드: 테이블 컬럼 조회
|
||
try {
|
||
const columns = await tableTypeApi.getColumns(sourceNode.data.tableName);
|
||
if (columns && Array.isArray(columns)) {
|
||
columns.forEach((col: any) => {
|
||
const colName = col.columnName || col.column_name;
|
||
if (!processedFields.has(colName)) {
|
||
processedFields.add(colName);
|
||
fields.push({
|
||
name: colName,
|
||
label: col.columnLabel || col.column_label || colName,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("트리거 노드 컬럼 로딩 실패:", error);
|
||
}
|
||
} else if (sourceNode.type === "tableSource" && sourceNode.data.tableName) {
|
||
// 테이블 소스 노드
|
||
try {
|
||
const columns = await tableTypeApi.getColumns(sourceNode.data.tableName);
|
||
if (columns && Array.isArray(columns)) {
|
||
columns.forEach((col: any) => {
|
||
const colName = col.columnName || col.column_name;
|
||
if (!processedFields.has(colName)) {
|
||
processedFields.add(colName);
|
||
fields.push({
|
||
name: colName,
|
||
label: col.columnLabel || col.column_label || colName,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("테이블 소스 노드 컬럼 로딩 실패:", error);
|
||
}
|
||
} else if (sourceNode.type === "condition") {
|
||
// 조건 노드: 연결된 이전 노드에서 필드 가져오기
|
||
const conditionIncomingEdges = edges.filter((e) => e.target === sourceNode.id);
|
||
for (const condEdge of conditionIncomingEdges) {
|
||
const condSourceNode = nodes.find((n) => n.id === condEdge.source);
|
||
if (condSourceNode?.type === "trigger" && condSourceNode.data.tableName) {
|
||
try {
|
||
const columns = await tableTypeApi.getColumns(condSourceNode.data.tableName);
|
||
if (columns && Array.isArray(columns)) {
|
||
columns.forEach((col: any) => {
|
||
const colName = col.columnName || col.column_name;
|
||
if (!processedFields.has(colName)) {
|
||
processedFields.add(colName);
|
||
fields.push({
|
||
name: colName,
|
||
label: col.columnLabel || col.column_label || colName,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("조건 노드 소스 컬럼 로딩 실패:", error);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log("✅ DELETE 노드 소스 필드:", fields);
|
||
setSourceFields(fields);
|
||
}, [nodeId, nodes, edges]);
|
||
|
||
// 소스 필드 로딩
|
||
useEffect(() => {
|
||
loadSourceFields();
|
||
}, [loadSourceFields]);
|
||
|
||
const loadExternalConnections = async () => {
|
||
try {
|
||
setExternalConnectionsLoading(true);
|
||
|
||
const cached = getExternalConnectionsCache();
|
||
if (cached) {
|
||
setExternalConnections(cached);
|
||
return;
|
||
}
|
||
|
||
const data = await getTestedExternalConnections();
|
||
setExternalConnections(data);
|
||
} catch (error) {
|
||
console.error("외부 커넥션 로딩 실패:", error);
|
||
} finally {
|
||
setExternalConnectionsLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadExternalTables = async (connectionId: number) => {
|
||
try {
|
||
setExternalTablesLoading(true);
|
||
const data = await getExternalTables(connectionId);
|
||
setExternalTables(data);
|
||
} catch (error) {
|
||
console.error("외부 테이블 로딩 실패:", error);
|
||
} finally {
|
||
setExternalTablesLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadExternalColumns = async (connectionId: number, tableName: string) => {
|
||
try {
|
||
setExternalColumnsLoading(true);
|
||
const data = await getExternalColumns(connectionId, tableName);
|
||
setExternalColumns(data);
|
||
} catch (error) {
|
||
console.error("외부 컬럼 로딩 실패:", error);
|
||
} finally {
|
||
setExternalColumnsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleTargetTypeChange = (newType: "internal" | "external" | "api") => {
|
||
setTargetType(newType);
|
||
updateNode(nodeId, {
|
||
targetType: newType,
|
||
targetTable: newType === "internal" ? targetTable : undefined,
|
||
externalConnectionId: newType === "external" ? selectedExternalConnectionId : undefined,
|
||
externalTargetTable: newType === "external" ? externalTargetTable : undefined,
|
||
apiEndpoint: newType === "api" ? apiEndpoint : undefined,
|
||
apiAuthType: newType === "api" ? apiAuthType : undefined,
|
||
apiAuthConfig: newType === "api" ? apiAuthConfig : undefined,
|
||
apiHeaders: newType === "api" ? apiHeaders : undefined,
|
||
});
|
||
};
|
||
|
||
// 🔥 테이블 목록 로딩
|
||
const loadTables = async () => {
|
||
try {
|
||
setTablesLoading(true);
|
||
const tableList = await tableTypeApi.getTables();
|
||
setTables(tableList);
|
||
} catch (error) {
|
||
console.error("테이블 목록 로딩 실패:", error);
|
||
} finally {
|
||
setTablesLoading(false);
|
||
}
|
||
};
|
||
|
||
// 🔥 내부 DB 컬럼 로딩
|
||
const loadColumns = async (tableName: string) => {
|
||
try {
|
||
setColumnsLoading(true);
|
||
const response = await tableTypeApi.getColumns(tableName);
|
||
if (response && Array.isArray(response)) {
|
||
const columnInfos: ColumnInfo[] = response.map((col: any) => ({
|
||
columnName: col.columnName || col.column_name,
|
||
columnLabel: col.columnLabel || col.column_label,
|
||
dataType: col.dataType || col.data_type || "text",
|
||
isNullable: col.isNullable !== undefined ? col.isNullable : true,
|
||
}));
|
||
setTargetColumns(columnInfos);
|
||
}
|
||
} catch (error) {
|
||
console.error("컬럼 로딩 실패:", error);
|
||
setTargetColumns([]);
|
||
} finally {
|
||
setColumnsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleTableSelect = (tableName: string) => {
|
||
const selectedTable = tables.find((t: any) => t.tableName === tableName);
|
||
const label = (selectedTable as any)?.tableLabel || selectedTable?.displayName || tableName;
|
||
|
||
setTargetTable(tableName);
|
||
setSelectedTableLabel(label);
|
||
setTablesOpen(false);
|
||
|
||
updateNode(nodeId, {
|
||
targetTable: tableName,
|
||
displayName: label,
|
||
});
|
||
};
|
||
|
||
const handleAddCondition = () => {
|
||
const newConditions = [
|
||
...whereConditions,
|
||
{
|
||
field: "",
|
||
operator: "EQUALS",
|
||
value: "",
|
||
sourceField: undefined,
|
||
staticValue: undefined,
|
||
},
|
||
];
|
||
setWhereConditions(newConditions);
|
||
setFieldOpenState(new Array(newConditions.length).fill(false));
|
||
setSourceFieldsOpenState(new Array(newConditions.length).fill(false));
|
||
|
||
// 자동 저장
|
||
updateNode(nodeId, {
|
||
whereConditions: newConditions,
|
||
});
|
||
};
|
||
|
||
const handleRemoveCondition = (index: number) => {
|
||
const newConditions = whereConditions.filter((_, i) => i !== index);
|
||
setWhereConditions(newConditions);
|
||
setFieldOpenState(new Array(newConditions.length).fill(false));
|
||
setSourceFieldsOpenState(new Array(newConditions.length).fill(false));
|
||
|
||
// 자동 저장
|
||
updateNode(nodeId, {
|
||
whereConditions: newConditions,
|
||
});
|
||
};
|
||
|
||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||
const newConditions = [...whereConditions];
|
||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||
setWhereConditions(newConditions);
|
||
|
||
// 자동 저장
|
||
updateNode(nodeId, {
|
||
whereConditions: newConditions,
|
||
});
|
||
};
|
||
|
||
const handleSave = () => {
|
||
updateNode(nodeId, {
|
||
displayName,
|
||
targetTable,
|
||
whereConditions,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<div className="space-y-4 p-4 pb-8">
|
||
{/* 경고 */}
|
||
<div className="rounded-lg border-2 border-red-200 bg-red-50 p-4">
|
||
<div className="flex items-start gap-3">
|
||
<AlertTriangle className="h-5 w-5 flex-shrink-0 text-red-600" />
|
||
<div className="text-sm">
|
||
<p className="font-semibold text-red-800">위험한 작업입니다!</p>
|
||
<p className="mt-1 text-xs text-red-700">
|
||
DELETE 작업은 되돌릴 수 없습니다. WHERE 조건을 반드시 설정하세요.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 기본 정보 */}
|
||
<div>
|
||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label htmlFor="displayName" className="text-xs">
|
||
표시 이름
|
||
</Label>
|
||
<Input
|
||
id="displayName"
|
||
value={displayName}
|
||
onChange={(e) => setDisplayName(e.target.value)}
|
||
className="mt-1"
|
||
/>
|
||
</div>
|
||
|
||
{/* 🔥 타겟 타입 선택 */}
|
||
<div>
|
||
<Label className="mb-2 block text-xs font-medium">타겟 선택</Label>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleTargetTypeChange("internal")}
|
||
className={cn(
|
||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||
targetType === "internal" ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
|
||
)}
|
||
>
|
||
<Database className={cn("h-5 w-5", targetType === "internal" ? "text-blue-600" : "text-gray-400")} />
|
||
<span
|
||
className={cn("text-xs font-medium", targetType === "internal" ? "text-blue-700" : "text-gray-600")}
|
||
>
|
||
내부 DB
|
||
</span>
|
||
{targetType === "internal" && <Check className="absolute top-2 right-2 h-4 w-4 text-blue-600" />}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => handleTargetTypeChange("external")}
|
||
className={cn(
|
||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||
targetType === "external"
|
||
? "border-green-500 bg-green-50"
|
||
: "border-gray-200 hover:border-gray-300",
|
||
)}
|
||
>
|
||
<Globe className={cn("h-5 w-5", targetType === "external" ? "text-green-600" : "text-gray-400")} />
|
||
<span
|
||
className={cn(
|
||
"text-xs font-medium",
|
||
targetType === "external" ? "text-green-700" : "text-gray-600",
|
||
)}
|
||
>
|
||
외부 DB
|
||
</span>
|
||
{targetType === "external" && <Check className="absolute top-2 right-2 h-4 w-4 text-green-600" />}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => handleTargetTypeChange("api")}
|
||
className={cn(
|
||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||
targetType === "api" ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300",
|
||
)}
|
||
>
|
||
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
|
||
<span
|
||
className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}
|
||
>
|
||
REST API
|
||
</span>
|
||
{targetType === "api" && <Check className="absolute top-2 right-2 h-4 w-4 text-purple-600" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 내부 DB: 타겟 테이블 Combobox */}
|
||
{targetType === "internal" && (
|
||
<div>
|
||
<Label className="text-xs">타겟 테이블</Label>
|
||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
role="combobox"
|
||
aria-expanded={tablesOpen}
|
||
className="mt-1 w-full justify-between"
|
||
disabled={tablesLoading}
|
||
>
|
||
{tablesLoading ? (
|
||
<span className="text-muted-foreground">로딩 중...</span>
|
||
) : targetTable ? (
|
||
<span className="truncate">{selectedTableLabel}</span>
|
||
) : (
|
||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
||
)}
|
||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-[320px] p-0" align="start">
|
||
<Command>
|
||
<CommandInput placeholder="테이블 검색..." />
|
||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||
<CommandList>
|
||
<CommandGroup>
|
||
{tables.map((table: any) => (
|
||
<CommandItem
|
||
key={table.tableName}
|
||
value={`${table.tableLabel || table.displayName} ${table.tableName}`}
|
||
onSelect={() => handleTableSelect(table.tableName)}
|
||
>
|
||
<Check
|
||
className={cn(
|
||
"mr-2 h-4 w-4",
|
||
targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
||
)}
|
||
/>
|
||
<div className="flex flex-col">
|
||
<span className="font-medium">{table.tableLabel || table.displayName}</span>
|
||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||
</div>
|
||
</CommandItem>
|
||
))}
|
||
</CommandGroup>
|
||
</CommandList>
|
||
</Command>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
)}
|
||
|
||
{/* 🔥 외부 DB 설정 */}
|
||
{targetType === "external" && (
|
||
<>
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs font-medium">외부 데이터베이스 커넥션</Label>
|
||
<Select
|
||
value={selectedExternalConnectionId?.toString()}
|
||
onValueChange={(value) => {
|
||
const connectionId = parseInt(value);
|
||
const selectedConnection = externalConnections.find((c) => c.id === connectionId);
|
||
setSelectedExternalConnectionId(connectionId);
|
||
setExternalTargetTable("");
|
||
setExternalColumns([]);
|
||
updateNode(nodeId, {
|
||
externalConnectionId: connectionId,
|
||
externalConnectionName: selectedConnection?.name,
|
||
externalDbType: selectedConnection?.db_type,
|
||
});
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-8 text-xs">
|
||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{externalConnectionsLoading ? (
|
||
<div className="p-2 text-center text-xs text-gray-500">로딩 중...</div>
|
||
) : externalConnections.length === 0 ? (
|
||
<div className="p-2 text-center text-xs text-gray-500">사용 가능한 커넥션이 없습니다</div>
|
||
) : (
|
||
externalConnections.map((conn) => (
|
||
<SelectItem key={conn.id} value={conn.id!.toString()}>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium">{conn.db_type}</span>
|
||
<span className="text-gray-500">-</span>
|
||
<span>{conn.name}</span>
|
||
</div>
|
||
</SelectItem>
|
||
))
|
||
)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{selectedExternalConnectionId && (
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs font-medium">테이블</Label>
|
||
<Select
|
||
value={externalTargetTable}
|
||
onValueChange={(value) => {
|
||
const selectedTable = externalTables.find((t) => t.table_name === value);
|
||
setExternalTargetTable(value);
|
||
updateNode(nodeId, {
|
||
externalTargetTable: value,
|
||
externalTargetSchema: selectedTable?.schema,
|
||
});
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-8 text-xs">
|
||
<SelectValue placeholder="테이블을 선택하세요" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{externalTablesLoading ? (
|
||
<div className="p-2 text-center text-xs text-gray-500">로딩 중...</div>
|
||
) : externalTables.length === 0 ? (
|
||
<div className="p-2 text-center text-xs text-gray-500">테이블이 없습니다</div>
|
||
) : (
|
||
externalTables.map((table) => (
|
||
<SelectItem key={table.table_name} value={table.table_name}>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium">{table.table_name}</span>
|
||
{table.schema && <span className="text-xs text-gray-500">({table.schema})</span>}
|
||
</div>
|
||
</SelectItem>
|
||
))
|
||
)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
{externalTargetTable && externalColumns.length > 0 && (
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs font-medium">컬럼 목록</Label>
|
||
<div className="max-h-32 space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
|
||
{externalColumns.map((col) => (
|
||
<div key={col.column_name} className="flex items-center justify-between text-xs">
|
||
<span className="font-medium">{col.column_name}</span>
|
||
<span className="text-gray-500">{col.data_type}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* 🔥 REST API 설정 (DELETE는 간단함) */}
|
||
{targetType === "api" && (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs font-medium">API 엔드포인트</Label>
|
||
<Input
|
||
placeholder="https://api.example.com/v1/users/{id}"
|
||
value={apiEndpoint}
|
||
onChange={(e) => {
|
||
setApiEndpoint(e.target.value);
|
||
updateNode(nodeId, { apiEndpoint: e.target.value });
|
||
}}
|
||
className="h-8 text-xs"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs font-medium">인증 방식</Label>
|
||
<Select
|
||
value={apiAuthType}
|
||
onValueChange={(value: "none" | "basic" | "bearer" | "apikey") => {
|
||
setApiAuthType(value);
|
||
updateNode(nodeId, { apiAuthType: value });
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-8 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">인증 없음</SelectItem>
|
||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||
<SelectItem value="apikey">API Key</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{apiAuthType !== "none" && (
|
||
<div className="space-y-2 rounded border bg-gray-50 p-3">
|
||
<Label className="block text-xs font-medium">인증 정보</Label>
|
||
|
||
{apiAuthType === "bearer" && (
|
||
<Input
|
||
placeholder="Bearer Token"
|
||
value={(apiAuthConfig as any)?.token || ""}
|
||
onChange={(e) => {
|
||
const newConfig = { token: e.target.value };
|
||
setApiAuthConfig(newConfig);
|
||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||
}}
|
||
className="h-8 text-xs"
|
||
/>
|
||
)}
|
||
|
||
{apiAuthType === "basic" && (
|
||
<div className="space-y-2">
|
||
<Input
|
||
placeholder="사용자명"
|
||
value={(apiAuthConfig as any)?.username || ""}
|
||
onChange={(e) => {
|
||
const newConfig = { ...(apiAuthConfig as any), username: e.target.value };
|
||
setApiAuthConfig(newConfig);
|
||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||
}}
|
||
className="h-8 text-xs"
|
||
/>
|
||
<Input
|
||
type="password"
|
||
placeholder="비밀번호"
|
||
value={(apiAuthConfig as any)?.password || ""}
|
||
onChange={(e) => {
|
||
const newConfig = { ...(apiAuthConfig as any), password: e.target.value };
|
||
setApiAuthConfig(newConfig);
|
||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||
}}
|
||
className="h-8 text-xs"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{apiAuthType === "apikey" && (
|
||
<div className="space-y-2">
|
||
<Input
|
||
placeholder="헤더 이름 (예: X-API-Key)"
|
||
value={(apiAuthConfig as any)?.headerName || ""}
|
||
onChange={(e) => {
|
||
const newConfig = { ...(apiAuthConfig as any), headerName: e.target.value };
|
||
setApiAuthConfig(newConfig);
|
||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||
}}
|
||
className="h-8 text-xs"
|
||
/>
|
||
<Input
|
||
placeholder="API Key"
|
||
value={(apiAuthConfig as any)?.apiKey || ""}
|
||
onChange={(e) => {
|
||
const newConfig = { ...(apiAuthConfig as any), apiKey: e.target.value };
|
||
setApiAuthConfig(newConfig);
|
||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||
}}
|
||
className="h-8 text-xs"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs font-medium">커스텀 헤더 (선택사항)</Label>
|
||
<div className="space-y-2 rounded border bg-gray-50 p-3">
|
||
{Object.entries(apiHeaders).map(([key, value], index) => (
|
||
<div key={index} className="flex gap-2">
|
||
<Input
|
||
placeholder="헤더 이름"
|
||
value={key}
|
||
onChange={(e) => {
|
||
const newHeaders = { ...apiHeaders };
|
||
delete newHeaders[key];
|
||
newHeaders[e.target.value] = value;
|
||
setApiHeaders(newHeaders);
|
||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||
}}
|
||
className="h-7 flex-1 text-xs"
|
||
/>
|
||
<Input
|
||
placeholder="헤더 값"
|
||
value={value}
|
||
onChange={(e) => {
|
||
const newHeaders = { ...apiHeaders, [key]: e.target.value };
|
||
setApiHeaders(newHeaders);
|
||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||
}}
|
||
className="h-7 flex-1 text-xs"
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => {
|
||
const newHeaders = { ...apiHeaders };
|
||
delete newHeaders[key];
|
||
setApiHeaders(newHeaders);
|
||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||
}}
|
||
className="h-7 w-7 p-0"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => {
|
||
const newHeaders = { ...apiHeaders, "": "" };
|
||
setApiHeaders(newHeaders);
|
||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||
}}
|
||
className="h-7 w-full text-xs"
|
||
>
|
||
<Plus className="mr-1 h-3 w-3" />
|
||
헤더 추가
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* WHERE 조건 */}
|
||
<div>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<h3 className="text-sm font-semibold">WHERE 조건 (필수)</h3>
|
||
<Button size="sm" variant="outline" onClick={handleAddCondition} className="h-7">
|
||
<Plus className="mr-1 h-3 w-3" />
|
||
추가
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 컬럼 로딩 상태 */}
|
||
{targetType === "internal" && targetTable && columnsLoading && (
|
||
<div className="rounded border border-gray-200 bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||
컬럼 정보를 불러오는 중...
|
||
</div>
|
||
)}
|
||
|
||
{/* 테이블 미선택 안내 */}
|
||
{targetType === "internal" && !targetTable && (
|
||
<div className="rounded border border-dashed border-gray-300 bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||
먼저 타겟 테이블을 선택하세요
|
||
</div>
|
||
)}
|
||
|
||
{whereConditions.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{whereConditions.map((condition, index) => {
|
||
// 현재 타입에 따라 사용 가능한 컬럼 리스트 결정
|
||
const availableColumns =
|
||
targetType === "internal"
|
||
? targetColumns
|
||
: targetType === "external"
|
||
? externalColumns.map((col) => ({
|
||
columnName: col.column_name,
|
||
columnLabel: col.column_name,
|
||
dataType: col.data_type,
|
||
isNullable: true,
|
||
}))
|
||
: [];
|
||
|
||
return (
|
||
<div key={index} className="rounded border-2 border-red-200 bg-red-50 p-2">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<span className="text-xs font-medium text-red-700">조건 #{index + 1}</span>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => handleRemoveCondition(index)}
|
||
className="h-6 w-6 p-0"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{/* 필드 - Combobox */}
|
||
<div>
|
||
<Label className="text-xs text-gray-600">필드</Label>
|
||
{availableColumns.length > 0 ? (
|
||
<Popover
|
||
open={fieldOpenState[index]}
|
||
onOpenChange={(open) => {
|
||
const newState = [...fieldOpenState];
|
||
newState[index] = open;
|
||
setFieldOpenState(newState);
|
||
}}
|
||
>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
role="combobox"
|
||
aria-expanded={fieldOpenState[index]}
|
||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||
>
|
||
{condition.field
|
||
? (() => {
|
||
const col = availableColumns.find((c) => c.columnName === condition.field);
|
||
return (
|
||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||
<span className="truncate font-mono">
|
||
{col?.columnLabel || condition.field}
|
||
</span>
|
||
<span className="text-muted-foreground text-xs">{col?.dataType}</span>
|
||
</div>
|
||
);
|
||
})()
|
||
: "필드 선택"}
|
||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
className="p-0"
|
||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||
align="start"
|
||
>
|
||
<Command>
|
||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
||
<CommandList>
|
||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||
<CommandGroup>
|
||
{availableColumns.map((col) => (
|
||
<CommandItem
|
||
key={col.columnName}
|
||
value={col.columnName}
|
||
onSelect={(currentValue) => {
|
||
handleConditionChange(index, "field", currentValue);
|
||
const newState = [...fieldOpenState];
|
||
newState[index] = false;
|
||
setFieldOpenState(newState);
|
||
}}
|
||
className="text-xs sm:text-sm"
|
||
>
|
||
<Check
|
||
className={cn(
|
||
"mr-2 h-4 w-4",
|
||
condition.field === col.columnName ? "opacity-100" : "opacity-0",
|
||
)}
|
||
/>
|
||
<div className="flex flex-col">
|
||
<span className="font-mono font-medium">
|
||
{col.columnLabel || col.columnName}
|
||
</span>
|
||
<span className="text-muted-foreground text-[10px]">{col.dataType}</span>
|
||
</div>
|
||
</CommandItem>
|
||
))}
|
||
</CommandGroup>
|
||
</CommandList>
|
||
</Command>
|
||
</PopoverContent>
|
||
</Popover>
|
||
) : (
|
||
<Input
|
||
value={condition.field}
|
||
onChange={(e) => handleConditionChange(index, "field", e.target.value)}
|
||
placeholder="조건 필드명"
|
||
className="mt-1 h-8 text-xs"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-xs text-gray-600">연산자</Label>
|
||
<Select
|
||
value={condition.operator}
|
||
onValueChange={(value) => handleConditionChange(index, "operator", value)}
|
||
>
|
||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{OPERATORS.map((op) => (
|
||
<SelectItem key={op.value} value={op.value}>
|
||
{op.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 🆕 소스 필드 - Combobox */}
|
||
<div>
|
||
<Label className="text-xs text-gray-600">소스 필드 (선택)</Label>
|
||
{sourceFields.length > 0 ? (
|
||
<Popover
|
||
open={sourceFieldsOpenState[index]}
|
||
onOpenChange={(open) => {
|
||
const newState = [...sourceFieldsOpenState];
|
||
newState[index] = open;
|
||
setSourceFieldsOpenState(newState);
|
||
}}
|
||
>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
role="combobox"
|
||
aria-expanded={sourceFieldsOpenState[index]}
|
||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||
>
|
||
{condition.sourceField
|
||
? (() => {
|
||
const field = sourceFields.find((f) => f.name === condition.sourceField);
|
||
return (
|
||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||
<span className="truncate font-medium">
|
||
{field?.label || condition.sourceField}
|
||
</span>
|
||
{field?.label && field.label !== field.name && (
|
||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})()
|
||
: "소스 필드 선택 (선택)"}
|
||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
className="p-0"
|
||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||
align="start"
|
||
>
|
||
<Command>
|
||
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
|
||
<CommandList>
|
||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||
<CommandGroup>
|
||
<CommandItem
|
||
value="_NONE_"
|
||
onSelect={() => {
|
||
handleConditionChange(index, "sourceField", undefined);
|
||
const newState = [...sourceFieldsOpenState];
|
||
newState[index] = false;
|
||
setSourceFieldsOpenState(newState);
|
||
}}
|
||
className="text-xs text-gray-400 sm:text-sm"
|
||
>
|
||
<Check
|
||
className={cn(
|
||
"mr-2 h-4 w-4",
|
||
!condition.sourceField ? "opacity-100" : "opacity-0",
|
||
)}
|
||
/>
|
||
없음 (정적 값 사용)
|
||
</CommandItem>
|
||
{sourceFields.map((field) => (
|
||
<CommandItem
|
||
key={field.name}
|
||
value={field.name}
|
||
onSelect={(currentValue) => {
|
||
handleConditionChange(index, "sourceField", currentValue);
|
||
const newState = [...sourceFieldsOpenState];
|
||
newState[index] = false;
|
||
setSourceFieldsOpenState(newState);
|
||
}}
|
||
className="text-xs sm:text-sm"
|
||
>
|
||
<Check
|
||
className={cn(
|
||
"mr-2 h-4 w-4",
|
||
condition.sourceField === field.name ? "opacity-100" : "opacity-0",
|
||
)}
|
||
/>
|
||
<div className="flex flex-col">
|
||
<span className="font-medium">{field.label || field.name}</span>
|
||
{field.label && field.label !== field.name && (
|
||
<span className="text-muted-foreground font-mono text-[10px]">
|
||
{field.name}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</CommandItem>
|
||
))}
|
||
</CommandGroup>
|
||
</CommandList>
|
||
</Command>
|
||
</PopoverContent>
|
||
</Popover>
|
||
) : (
|
||
<div className="mt-1 rounded border border-dashed border-gray-300 bg-gray-50 p-2 text-center text-xs text-gray-500">
|
||
연결된 소스 노드가 없습니다
|
||
</div>
|
||
)}
|
||
<p className="mt-1 text-xs text-gray-400">소스 데이터에서 값을 가져올 필드</p>
|
||
</div>
|
||
|
||
{/* 정적 값 */}
|
||
<div>
|
||
<Label className="text-xs text-gray-600">정적 값</Label>
|
||
<Input
|
||
value={condition.staticValue || condition.value || ""}
|
||
onChange={(e) => {
|
||
handleConditionChange(index, "staticValue", e.target.value || undefined);
|
||
handleConditionChange(index, "value", e.target.value);
|
||
}}
|
||
placeholder="비교할 고정 값"
|
||
className="mt-1 h-8 text-xs"
|
||
/>
|
||
<p className="mt-1 text-xs text-gray-400">소스 필드가 비어있을 때 사용됩니다</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="rounded border-2 border-dashed border-red-300 bg-red-50 p-4 text-center text-xs text-red-600">
|
||
⚠️ WHERE 조건이 없습니다! 모든 데이터가 삭제됩니다!
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="rounded bg-red-50 p-3 text-xs text-red-700">
|
||
🚨 WHERE 조건 없이 삭제하면 테이블의 모든 데이터가 영구 삭제됩니다!
|
||
</div>
|
||
<div className="rounded bg-red-50 p-3 text-xs text-red-700">💡 실행 전 WHERE 조건을 꼭 확인하세요.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|