502 lines
20 KiB
TypeScript
502 lines
20 KiB
TypeScript
/**
|
|
* 플로우 조건 빌더
|
|
* 동적 조건 생성 UI
|
|
*/
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { Plus, Trash2, Check, ChevronsUpDown } from "lucide-react";
|
|
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 { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { FlowConditionGroup, FlowCondition, ConditionOperator } from "@/types/flow";
|
|
import { getTableColumns } from "@/lib/api/tableManagement";
|
|
import { ExternalRestApiConnectionAPI } from "@/lib/api/externalRestApiConnection";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// 다중 REST API 연결 설정
|
|
interface RestApiConnectionConfig {
|
|
connectionId: number;
|
|
connectionName: string;
|
|
endpoint: string;
|
|
jsonPath: string;
|
|
alias: string;
|
|
}
|
|
|
|
interface FlowConditionBuilderProps {
|
|
flowId: number;
|
|
tableName?: string; // 조회할 테이블명
|
|
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi"; // DB 소스 타입
|
|
dbConnectionId?: number; // 외부 DB 연결 ID
|
|
restApiConnectionId?: number; // REST API 연결 ID (단일)
|
|
restApiEndpoint?: string; // REST API 엔드포인트 (단일)
|
|
restApiJsonPath?: string; // REST API JSON 경로 (단일)
|
|
restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정
|
|
condition?: FlowConditionGroup;
|
|
onChange: (condition: FlowConditionGroup | undefined) => void;
|
|
}
|
|
|
|
const OPERATORS: { value: ConditionOperator; label: string }[] = [
|
|
{ value: "equals", label: "같음 (=)" },
|
|
{ value: "not_equals", label: "같지 않음 (!=)" },
|
|
{ value: "greater_than", label: "보다 큼 (>)" },
|
|
{ value: "less_than", label: "보다 작음 (<)" },
|
|
{ value: "greater_than_or_equal", label: "이상 (>=)" },
|
|
{ value: "less_than_or_equal", label: "이하 (<=)" },
|
|
{ value: "in", label: "포함 (IN)" },
|
|
{ value: "not_in", label: "제외 (NOT IN)" },
|
|
{ value: "like", label: "유사 (LIKE)" },
|
|
{ value: "not_like", label: "유사하지 않음 (NOT LIKE)" },
|
|
{ value: "is_null", label: "NULL" },
|
|
{ value: "is_not_null", label: "NOT NULL" },
|
|
];
|
|
|
|
export function FlowConditionBuilder({
|
|
flowId,
|
|
tableName,
|
|
dbSourceType = "internal",
|
|
dbConnectionId,
|
|
restApiConnectionId,
|
|
restApiEndpoint,
|
|
restApiJsonPath,
|
|
restApiConnections,
|
|
condition,
|
|
onChange,
|
|
}: FlowConditionBuilderProps) {
|
|
const [columns, setColumns] = useState<any[]>([]);
|
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
|
const [conditionType, setConditionType] = useState<"AND" | "OR">(condition?.type || "AND");
|
|
const [conditions, setConditions] = useState<FlowCondition[]>(condition?.conditions || []);
|
|
const [columnComboboxOpen, setColumnComboboxOpen] = useState<Record<number, boolean>>({});
|
|
|
|
// condition prop이 변경될 때 상태 동기화
|
|
useEffect(() => {
|
|
if (condition) {
|
|
setConditionType(condition.type || "AND");
|
|
setConditions(condition.conditions || []);
|
|
} else {
|
|
setConditionType("AND");
|
|
setConditions([]);
|
|
}
|
|
}, [condition]);
|
|
|
|
// 테이블 컬럼 로드 - 내부/외부 DB 및 REST API 모두 지원
|
|
useEffect(() => {
|
|
// REST API인 경우 tableName이 없어도 진행 가능
|
|
if (!tableName && dbSourceType !== "restapi" && dbSourceType !== "multi_restapi") {
|
|
setColumns([]);
|
|
return;
|
|
}
|
|
|
|
const loadColumns = async () => {
|
|
try {
|
|
setLoadingColumns(true);
|
|
console.log("🔍 [FlowConditionBuilder] Loading columns:", {
|
|
tableName,
|
|
dbSourceType,
|
|
dbConnectionId,
|
|
restApiConnectionId,
|
|
restApiEndpoint,
|
|
restApiJsonPath,
|
|
restApiConnections,
|
|
});
|
|
|
|
// 다중 REST API인 경우
|
|
if (dbSourceType === "multi_restapi" && restApiConnections && restApiConnections.length > 0) {
|
|
try {
|
|
console.log("🌐 [FlowConditionBuilder] 다중 REST API 컬럼 로드 시작:", restApiConnections);
|
|
|
|
// 각 API에서 컬럼 정보 수집
|
|
const allColumns: any[] = [];
|
|
|
|
for (const config of restApiConnections) {
|
|
try {
|
|
const effectiveJsonPath = (!config.jsonPath || config.jsonPath === "data") ? "response" : config.jsonPath;
|
|
|
|
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
|
|
config.connectionId,
|
|
config.endpoint,
|
|
effectiveJsonPath,
|
|
);
|
|
|
|
if (restApiData.columns && restApiData.columns.length > 0) {
|
|
// 별칭 적용
|
|
const prefixedColumns = restApiData.columns.map((col) => ({
|
|
column_name: config.alias ? `${config.alias}${col.columnName}` : col.columnName,
|
|
data_type: col.dataType || "varchar",
|
|
displayName: `${col.columnLabel || col.columnName} (${config.connectionName})`,
|
|
sourceApi: config.connectionName,
|
|
}));
|
|
allColumns.push(...prefixedColumns);
|
|
}
|
|
} catch (apiError) {
|
|
console.warn(`API ${config.connectionId} 컬럼 로드 실패:`, apiError);
|
|
}
|
|
}
|
|
|
|
console.log("✅ [FlowConditionBuilder] 다중 REST API 컬럼 로드 완료:", allColumns.length, "items");
|
|
setColumns(allColumns);
|
|
} catch (multiApiError) {
|
|
console.error("❌ 다중 REST API 컬럼 로드 실패:", multiApiError);
|
|
setColumns([]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 단일 REST API인 경우 (dbSourceType이 restapi이거나 tableName이 _restapi_로 시작)
|
|
const isRestApi = dbSourceType === "restapi" || tableName?.startsWith("_restapi_");
|
|
|
|
// tableName에서 REST API 연결 ID 추출 (restApiConnectionId가 없는 경우)
|
|
let effectiveRestApiConnectionId = restApiConnectionId;
|
|
if (isRestApi && !effectiveRestApiConnectionId && tableName) {
|
|
const match = tableName.match(/_restapi_(\d+)/);
|
|
if (match) {
|
|
effectiveRestApiConnectionId = parseInt(match[1]);
|
|
console.log("🔍 tableName에서 REST API 연결 ID 추출:", effectiveRestApiConnectionId);
|
|
}
|
|
}
|
|
|
|
if (isRestApi && effectiveRestApiConnectionId) {
|
|
try {
|
|
// jsonPath가 "data"이거나 없으면 "response"로 변경 (thiratis API 응답 구조에 맞춤)
|
|
const effectiveJsonPath = (!restApiJsonPath || restApiJsonPath === "data") ? "response" : restApiJsonPath;
|
|
|
|
console.log("🌐 [FlowConditionBuilder] REST API 컬럼 로드 시작:", {
|
|
connectionId: effectiveRestApiConnectionId,
|
|
endpoint: restApiEndpoint,
|
|
jsonPath: restApiJsonPath,
|
|
effectiveJsonPath,
|
|
});
|
|
|
|
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
|
|
effectiveRestApiConnectionId,
|
|
restApiEndpoint,
|
|
effectiveJsonPath,
|
|
);
|
|
|
|
console.log("✅ [FlowConditionBuilder] REST API columns response:", restApiData);
|
|
|
|
if (restApiData.columns && restApiData.columns.length > 0) {
|
|
const columnList = restApiData.columns.map((col) => ({
|
|
column_name: col.columnName,
|
|
data_type: col.dataType || "varchar",
|
|
displayName: col.columnLabel || col.columnName,
|
|
}));
|
|
console.log("✅ Setting REST API columns:", columnList.length, "items", columnList);
|
|
setColumns(columnList);
|
|
} else {
|
|
console.warn("❌ No columns in REST API response");
|
|
setColumns([]);
|
|
}
|
|
} catch (restApiError) {
|
|
console.error("❌ REST API 컬럼 로드 실패:", restApiError);
|
|
setColumns([]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 외부 DB인 경우
|
|
if (dbSourceType === "external" && dbConnectionId) {
|
|
const token = localStorage.getItem("authToken");
|
|
if (!token) {
|
|
console.warn("토큰이 없습니다. 외부 DB 컬럼 목록을 조회할 수 없습니다.");
|
|
setColumns([]);
|
|
return;
|
|
}
|
|
|
|
const response = await fetch(
|
|
`/api/multi-connection/connections/${dbConnectionId}/tables/${tableName}/columns`,
|
|
{
|
|
credentials: "include",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
},
|
|
).catch((err) => {
|
|
console.warn("외부 DB 컬럼 fetch 실패:", err);
|
|
return null;
|
|
});
|
|
|
|
if (response && response.ok) {
|
|
const result = await response.json();
|
|
console.log("✅ [FlowConditionBuilder] External columns response:", result);
|
|
|
|
if (result.success && result.data) {
|
|
const columnList = Array.isArray(result.data)
|
|
? result.data.map((col: any) => ({
|
|
column_name: col.column_name || col.columnName || col.name,
|
|
data_type: col.data_type || col.dataType || col.type,
|
|
}))
|
|
: [];
|
|
console.log("✅ Setting external columns:", columnList.length, "items");
|
|
setColumns(columnList);
|
|
} else {
|
|
console.warn("❌ No data in external columns response");
|
|
setColumns([]);
|
|
}
|
|
} else {
|
|
console.warn(`외부 DB 컬럼 조회 실패: ${response?.status}`);
|
|
setColumns([]);
|
|
}
|
|
} else {
|
|
// 내부 DB인 경우 (기존 로직)
|
|
const response = await getTableColumns(tableName);
|
|
console.log("📦 [FlowConditionBuilder] Internal columns response:", response);
|
|
|
|
if (response.success && response.data?.columns) {
|
|
const columnArray = Array.isArray(response.data.columns) ? response.data.columns : [];
|
|
console.log("✅ Setting internal columns:", columnArray.length, "items");
|
|
setColumns(columnArray);
|
|
} else {
|
|
console.error("❌ Failed to load internal columns:", response.message);
|
|
setColumns([]);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ Exception loading columns:", error);
|
|
setColumns([]);
|
|
} finally {
|
|
setLoadingColumns(false);
|
|
}
|
|
};
|
|
|
|
loadColumns();
|
|
}, [tableName, dbSourceType, dbConnectionId, restApiConnectionId, restApiEndpoint, restApiJsonPath]);
|
|
|
|
// 조건 변경 시 부모에 전달
|
|
useEffect(() => {
|
|
if (conditions.length === 0) {
|
|
onChange(undefined);
|
|
} else {
|
|
onChange({
|
|
type: conditionType,
|
|
conditions,
|
|
});
|
|
}
|
|
}, [conditionType, conditions]);
|
|
|
|
// 조건 추가
|
|
const addCondition = () => {
|
|
setConditions([
|
|
...conditions,
|
|
{
|
|
column: "",
|
|
operator: "equals",
|
|
value: "",
|
|
},
|
|
]);
|
|
};
|
|
|
|
// 조건 수정
|
|
const updateCondition = (index: number, field: keyof FlowCondition, value: any) => {
|
|
const newConditions = [...conditions];
|
|
newConditions[index] = {
|
|
...newConditions[index],
|
|
[field]: value,
|
|
};
|
|
setConditions(newConditions);
|
|
};
|
|
|
|
// 조건 삭제
|
|
const removeCondition = (index: number) => {
|
|
setConditions(conditions.filter((_, i) => i !== index));
|
|
};
|
|
|
|
// value가 필요 없는 연산자 체크
|
|
const needsValue = (operator: ConditionOperator) => {
|
|
return operator !== "is_null" && operator !== "is_not_null";
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 조건 타입 선택 */}
|
|
<div>
|
|
<Label>조건 결합 방식</Label>
|
|
<Select value={conditionType} onValueChange={(value) => setConditionType(value as "AND" | "OR")}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="AND">AND (모든 조건 만족)</SelectItem>
|
|
<SelectItem value="OR">OR (하나 이상 만족)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 조건 목록 */}
|
|
<div className="space-y-3">
|
|
{conditions.length === 0 ? (
|
|
<div className="text-muted-foreground rounded border-2 border-dashed py-4 text-center text-sm">
|
|
조건이 없습니다
|
|
<br />
|
|
조건을 추가하여 데이터를 필터링하세요
|
|
</div>
|
|
) : (
|
|
conditions.map((cond, index) => (
|
|
<div key={index} className="space-y-2 rounded border bg-gray-50 p-3">
|
|
{/* 조건 번호 및 삭제 버튼 */}
|
|
<div className="flex items-center justify-between">
|
|
<Badge variant="outline">조건 {index + 1}</Badge>
|
|
<Button variant="ghost" size="sm" onClick={() => removeCondition(index)}>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 컬럼 선택 */}
|
|
<div>
|
|
<Label className="text-xs">컬럼</Label>
|
|
{loadingColumns ? (
|
|
<Input value="컬럼 로딩 중..." disabled className="h-8" />
|
|
) : !Array.isArray(columns) || columns.length === 0 ? (
|
|
<Input
|
|
value={cond.column}
|
|
onChange={(e) => updateCondition(index, "column", e.target.value)}
|
|
placeholder="테이블을 먼저 선택하세요"
|
|
className="h-8"
|
|
/>
|
|
) : (
|
|
<Popover
|
|
open={columnComboboxOpen[index] || false}
|
|
onOpenChange={(open) => setColumnComboboxOpen({ ...columnComboboxOpen, [index]: open })}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={columnComboboxOpen[index] || false}
|
|
className="h-8 w-full justify-between text-xs font-normal"
|
|
>
|
|
{cond.column
|
|
? (() => {
|
|
const col = columns.find((c) => (c.column_name || c.columnName) === cond.column);
|
|
const displayName = col?.displayName || col?.display_name || cond.column;
|
|
const dataType = col?.data_type || col?.dataType || "";
|
|
return (
|
|
<span className="flex items-center gap-2">
|
|
<span className="font-medium">{displayName}</span>
|
|
{dataType && <span className="text-gray-500">({dataType})</span>}
|
|
</span>
|
|
);
|
|
})()
|
|
: "컬럼 선택"}
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[300px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-2 text-center text-xs text-gray-500">
|
|
컬럼을 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{columns.map((col, idx) => {
|
|
const columnName = col.column_name || col.columnName || "";
|
|
const dataType = col.data_type || col.dataType || "";
|
|
const displayName = col.displayName || col.display_name || columnName;
|
|
|
|
return (
|
|
<CommandItem
|
|
key={`${columnName}-${idx}`}
|
|
value={columnName}
|
|
onSelect={(currentValue) => {
|
|
updateCondition(index, "column", currentValue);
|
|
setColumnComboboxOpen({ ...columnComboboxOpen, [index]: false });
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
cond.column === columnName ? "opacity-100" : "opacity-0",
|
|
)}
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">{displayName}</span>
|
|
<span className="text-gray-500">({dataType})</span>
|
|
</div>
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
|
|
{/* 연산자 선택 */}
|
|
<div>
|
|
<Label className="text-xs">연산자</Label>
|
|
<Select
|
|
value={cond.operator}
|
|
onValueChange={(value) => updateCondition(index, "operator", value as ConditionOperator)}
|
|
>
|
|
<SelectTrigger className="h-8">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{OPERATORS.map((op) => (
|
|
<SelectItem key={op.value} value={op.value}>
|
|
{op.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 값 입력 */}
|
|
{needsValue(cond.operator) && (
|
|
<div>
|
|
<Label className="text-xs">값</Label>
|
|
<Input
|
|
value={cond.value || ""}
|
|
onChange={(e) => updateCondition(index, "value", e.target.value)}
|
|
placeholder="값 입력"
|
|
className="h-8"
|
|
/>
|
|
{(cond.operator === "in" || cond.operator === "not_in") && (
|
|
<p className="text-muted-foreground mt-1 text-xs">쉼표(,)로 구분하여 여러 값 입력</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* 조건 추가 버튼 */}
|
|
<Button variant="outline" size="sm" onClick={addCondition} className="w-full">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
조건 추가
|
|
</Button>
|
|
|
|
{/* 조건 요약 */}
|
|
{conditions.length > 0 && (
|
|
<div className="rounded bg-blue-50 p-3 text-sm">
|
|
<strong>조건 요약:</strong>
|
|
<div className="mt-2 space-y-1">
|
|
{conditions.map((cond, index) => (
|
|
<div key={index} className="flex items-center gap-2">
|
|
<Badge variant="secondary" className="text-xs">
|
|
{cond.column}
|
|
</Badge>
|
|
<span className="text-muted-foreground">
|
|
{OPERATORS.find((op) => op.value === cond.operator)?.label}
|
|
</span>
|
|
{needsValue(cond.operator) && <code className="rounded bg-white px-2 py-1 text-xs">{cond.value}</code>}
|
|
{index < conditions.length - 1 && <Badge>{conditionType}</Badge>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|