830 lines
33 KiB
TypeScript
830 lines
33 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 조건 분기 노드 속성 편집
|
|
*/
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { Plus, Trash2, Database, Search, 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 { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
|
import type { ConditionNodeData, ConditionOperator } from "@/types/node-editor";
|
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// 필드 정의
|
|
interface FieldDefinition {
|
|
name: string;
|
|
label?: string;
|
|
type?: string;
|
|
}
|
|
|
|
// 테이블 정보
|
|
interface TableInfo {
|
|
tableName: string;
|
|
tableLabel: string;
|
|
}
|
|
|
|
// 테이블 컬럼 정보
|
|
interface ColumnInfo {
|
|
columnName: string;
|
|
columnLabel: string;
|
|
dataType: string;
|
|
}
|
|
|
|
interface ConditionPropertiesProps {
|
|
nodeId: string;
|
|
data: ConditionNodeData;
|
|
}
|
|
|
|
const OPERATORS = [
|
|
{ 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: "LIKE", label: "포함 (LIKE)" },
|
|
{ value: "NOT_LIKE", label: "미포함 (NOT LIKE)" },
|
|
{ value: "IN", label: "IN" },
|
|
{ value: "NOT_IN", label: "NOT IN" },
|
|
{ value: "IS_NULL", label: "NULL" },
|
|
{ value: "IS_NOT_NULL", label: "NOT NULL" },
|
|
{ value: "EXISTS_IN", label: "다른 테이블에 존재함" },
|
|
{ value: "NOT_EXISTS_IN", label: "다른 테이블에 존재하지 않음" },
|
|
] as const;
|
|
|
|
// EXISTS 계열 연산자인지 확인
|
|
const isExistsOperator = (operator: string): boolean => {
|
|
return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
|
|
};
|
|
|
|
// 테이블 선택용 검색 가능한 Combobox
|
|
function TableCombobox({
|
|
tables,
|
|
value,
|
|
onSelect,
|
|
placeholder = "테이블 검색...",
|
|
}: {
|
|
tables: TableInfo[];
|
|
value: string;
|
|
onSelect: (value: string) => void;
|
|
placeholder?: string;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
const selectedTable = tables.find((t) => t.tableName === value);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className="mt-1 h-8 w-full justify-between text-xs"
|
|
>
|
|
{selectedTable ? (
|
|
<span className="truncate">
|
|
{selectedTable.tableLabel}
|
|
<span className="ml-1 text-gray-400">({selectedTable.tableName})</span>
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">테이블 선택</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={placeholder} className="h-8 text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
|
<CommandGroup className="max-h-[200px] overflow-y-auto">
|
|
{tables.map((table) => (
|
|
<CommandItem
|
|
key={table.tableName}
|
|
value={`${table.tableLabel} ${table.tableName}`}
|
|
onSelect={() => {
|
|
onSelect(table.tableName);
|
|
setOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check className={cn("mr-2 h-3 w-3", value === table.tableName ? "opacity-100" : "opacity-0")} />
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{table.tableLabel}</span>
|
|
<span className="text-[10px] text-gray-500">{table.tableName}</span>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
// 컬럼 선택용 검색 가능한 Combobox
|
|
function ColumnCombobox({
|
|
columns,
|
|
value,
|
|
onSelect,
|
|
placeholder = "컬럼 검색...",
|
|
}: {
|
|
columns: ColumnInfo[];
|
|
value: string;
|
|
onSelect: (value: string) => void;
|
|
placeholder?: string;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
const selectedColumn = columns.find((c) => c.columnName === value);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className="mt-1 h-8 w-full justify-between text-xs"
|
|
>
|
|
{selectedColumn ? (
|
|
<span className="truncate">
|
|
{selectedColumn.columnLabel}
|
|
<span className="ml-1 text-gray-400">({selectedColumn.columnName})</span>
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">컬럼 선택</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={placeholder} className="h-8 text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
|
<CommandGroup className="max-h-[200px] overflow-y-auto">
|
|
{columns.map((col) => (
|
|
<CommandItem
|
|
key={col.columnName}
|
|
value={`${col.columnLabel} ${col.columnName}`}
|
|
onSelect={() => {
|
|
onSelect(col.columnName);
|
|
setOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check className={cn("mr-2 h-3 w-3", value === col.columnName ? "opacity-100" : "opacity-0")} />
|
|
<span className="font-medium">{col.columnLabel}</span>
|
|
<span className="ml-1 text-[10px] text-gray-400">({col.columnName})</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
// 컬럼 선택 섹션 (자동 로드 포함)
|
|
function ColumnSelectSection({
|
|
lookupTable,
|
|
lookupField,
|
|
tableColumnsCache,
|
|
loadingColumns,
|
|
loadTableColumns,
|
|
onSelect,
|
|
}: {
|
|
lookupTable: string;
|
|
lookupField: string;
|
|
tableColumnsCache: Record<string, ColumnInfo[]>;
|
|
loadingColumns: Record<string, boolean>;
|
|
loadTableColumns: (tableName: string) => Promise<ColumnInfo[]>;
|
|
onSelect: (value: string) => void;
|
|
}) {
|
|
// 캐시에 없고 로딩 중이 아니면 자동으로 로드
|
|
useEffect(() => {
|
|
if (lookupTable && !tableColumnsCache[lookupTable] && !loadingColumns[lookupTable]) {
|
|
loadTableColumns(lookupTable);
|
|
}
|
|
}, [lookupTable, tableColumnsCache, loadingColumns, loadTableColumns]);
|
|
|
|
const isLoading = loadingColumns[lookupTable];
|
|
const columns = tableColumnsCache[lookupTable];
|
|
|
|
return (
|
|
<div>
|
|
<Label className="text-xs text-gray-600">
|
|
<Search className="mr-1 inline h-3 w-3" />
|
|
비교할 컬럼
|
|
</Label>
|
|
{isLoading ? (
|
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
|
컬럼 목록 로딩 중...
|
|
</div>
|
|
) : columns && columns.length > 0 ? (
|
|
<ColumnCombobox columns={columns} value={lookupField} onSelect={onSelect} placeholder="컬럼 검색..." />
|
|
) : (
|
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
|
컬럼 목록을 로드할 수 없습니다
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {
|
|
const { updateNode, nodes, edges } = useFlowEditorStore();
|
|
|
|
const [displayName, setDisplayName] = useState(data.displayName || "조건 분기");
|
|
const [conditions, setConditions] = useState(data.conditions || []);
|
|
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
|
|
const [availableFields, setAvailableFields] = useState<FieldDefinition[]>([]);
|
|
|
|
// EXISTS 연산자용 상태
|
|
const [allTables, setAllTables] = useState<TableInfo[]>([]);
|
|
const [tableColumnsCache, setTableColumnsCache] = useState<Record<string, ColumnInfo[]>>({});
|
|
const [loadingTables, setLoadingTables] = useState(false);
|
|
const [loadingColumns, setLoadingColumns] = useState<Record<string, boolean>>({});
|
|
|
|
// 데이터 변경 시 로컬 상태 업데이트
|
|
useEffect(() => {
|
|
setDisplayName(data.displayName || "조건 분기");
|
|
setConditions(data.conditions || []);
|
|
setLogic(data.logic || "AND");
|
|
}, [data]);
|
|
|
|
// 전체 테이블 목록 로드 (EXISTS 연산자용)
|
|
useEffect(() => {
|
|
const loadAllTables = async () => {
|
|
// 이미 EXISTS 연산자가 있거나 로드된 적이 있으면 스킵
|
|
if (allTables.length > 0) return;
|
|
|
|
// EXISTS 연산자가 하나라도 있으면 테이블 목록 로드
|
|
const hasExistsOperator = conditions.some((c) => isExistsOperator(c.operator));
|
|
if (!hasExistsOperator) return;
|
|
|
|
setLoadingTables(true);
|
|
try {
|
|
const response = await tableManagementApi.getTableList();
|
|
if (response.success && response.data) {
|
|
setAllTables(
|
|
response.data.map((t: any) => ({
|
|
tableName: t.tableName,
|
|
tableLabel: t.tableLabel || t.tableName,
|
|
}))
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("테이블 목록 로드 실패:", error);
|
|
} finally {
|
|
setLoadingTables(false);
|
|
}
|
|
};
|
|
|
|
loadAllTables();
|
|
}, [conditions, allTables.length]);
|
|
|
|
// 테이블 컬럼 로드 함수
|
|
const loadTableColumns = useCallback(
|
|
async (tableName: string): Promise<ColumnInfo[]> => {
|
|
// 캐시에 있으면 반환
|
|
if (tableColumnsCache[tableName]) {
|
|
return tableColumnsCache[tableName];
|
|
}
|
|
|
|
// 이미 로딩 중이면 스킵
|
|
if (loadingColumns[tableName]) {
|
|
return [];
|
|
}
|
|
|
|
// 로딩 상태 설정
|
|
setLoadingColumns((prev) => ({ ...prev, [tableName]: true }));
|
|
|
|
try {
|
|
// getColumnList 반환: { success, data: { columns, total, ... } }
|
|
const response = await tableManagementApi.getColumnList(tableName);
|
|
if (response.success && response.data && response.data.columns) {
|
|
const columns = response.data.columns.map((c: any) => ({
|
|
columnName: c.columnName,
|
|
columnLabel: c.columnLabel || c.columnName,
|
|
dataType: c.dataType,
|
|
}));
|
|
setTableColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
|
|
console.log(`✅ 테이블 ${tableName} 컬럼 로드 완료:`, columns.length, "개");
|
|
return columns;
|
|
} else {
|
|
console.warn(`⚠️ 테이블 ${tableName} 컬럼 조회 실패:`, response);
|
|
}
|
|
} catch (error) {
|
|
console.error(`❌ 테이블 ${tableName} 컬럼 로드 실패:`, error);
|
|
} finally {
|
|
setLoadingColumns((prev) => ({ ...prev, [tableName]: false }));
|
|
}
|
|
return [];
|
|
},
|
|
[tableColumnsCache, loadingColumns]
|
|
);
|
|
|
|
// EXISTS 연산자 선택 시 테이블 목록 강제 로드
|
|
const ensureTablesLoaded = useCallback(async () => {
|
|
if (allTables.length > 0) return;
|
|
|
|
setLoadingTables(true);
|
|
try {
|
|
const response = await tableManagementApi.getTableList();
|
|
if (response.success && response.data) {
|
|
setAllTables(
|
|
response.data.map((t: any) => ({
|
|
tableName: t.tableName,
|
|
tableLabel: t.tableLabel || t.tableName,
|
|
}))
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("테이블 목록 로드 실패:", error);
|
|
} finally {
|
|
setLoadingTables(false);
|
|
}
|
|
}, [allTables.length]);
|
|
|
|
// 🔥 연결된 소스 노드의 필드를 재귀적으로 수집
|
|
useEffect(() => {
|
|
const getAllSourceFields = (currentNodeId: string, visited: Set<string> = new Set()): FieldDefinition[] => {
|
|
if (visited.has(currentNodeId)) return [];
|
|
visited.add(currentNodeId);
|
|
|
|
const fields: FieldDefinition[] = [];
|
|
|
|
// 현재 노드로 들어오는 엣지 찾기
|
|
const incomingEdges = edges.filter((e) => e.target === currentNodeId);
|
|
|
|
for (const edge of incomingEdges) {
|
|
const sourceNode = nodes.find((n) => n.id === edge.source);
|
|
if (!sourceNode) continue;
|
|
|
|
const sourceData = sourceNode.data as any;
|
|
|
|
// 소스 노드 타입별 필드 수집
|
|
if (sourceNode.type === "tableSource") {
|
|
// Table Source: fields 사용
|
|
if (sourceData.fields && Array.isArray(sourceData.fields)) {
|
|
console.log("🔍 [ConditionProperties] Table Source 필드:", sourceData.fields);
|
|
fields.push(...sourceData.fields);
|
|
} else {
|
|
console.log("⚠️ [ConditionProperties] Table Source에 필드 없음:", sourceData);
|
|
}
|
|
} else if (sourceNode.type === "externalDBSource") {
|
|
// External DB Source: outputFields 사용
|
|
if (sourceData.outputFields && Array.isArray(sourceData.outputFields)) {
|
|
console.log("🔍 [ConditionProperties] External DB 필드:", sourceData.outputFields);
|
|
fields.push(...sourceData.outputFields);
|
|
} else {
|
|
console.log("⚠️ [ConditionProperties] External DB에 필드 없음:", sourceData);
|
|
}
|
|
} else if (sourceNode.type === "dataTransform") {
|
|
// Data Transform: 재귀적으로 상위 노드 필드 수집
|
|
const upperFields = getAllSourceFields(sourceNode.id, visited);
|
|
|
|
// Data Transform의 변환 결과 추가
|
|
if (sourceData.transformations && Array.isArray(sourceData.transformations)) {
|
|
const inPlaceFields = new Set<string>();
|
|
|
|
for (const transform of sourceData.transformations) {
|
|
const { sourceField, targetField } = transform;
|
|
|
|
// In-place 변환인지 확인
|
|
if (!targetField || targetField === sourceField) {
|
|
inPlaceFields.add(sourceField);
|
|
} else {
|
|
// 새로운 필드 생성
|
|
fields.push({ name: targetField, label: targetField });
|
|
}
|
|
}
|
|
|
|
// 원본 필드 중 in-place 변환되지 않은 것들 추가
|
|
for (const field of upperFields) {
|
|
if (!inPlaceFields.has(field.name)) {
|
|
fields.push(field);
|
|
} else {
|
|
// In-place 변환된 필드는 원본 이름으로 유지
|
|
fields.push(field);
|
|
}
|
|
}
|
|
} else {
|
|
fields.push(...upperFields);
|
|
}
|
|
} else if (sourceNode.type === "restAPISource") {
|
|
// REST API Source: responseFields 사용
|
|
if (sourceData.responseFields && Array.isArray(sourceData.responseFields)) {
|
|
console.log("🔍 [ConditionProperties] REST API 필드:", sourceData.responseFields);
|
|
fields.push(
|
|
...sourceData.responseFields.map((f: any) => ({
|
|
name: f.name || f.fieldName,
|
|
label: f.label || f.displayName || f.name,
|
|
type: f.dataType || f.type,
|
|
})),
|
|
);
|
|
} else {
|
|
console.log("⚠️ [ConditionProperties] REST API에 필드 없음:", sourceData);
|
|
}
|
|
} else if (sourceNode.type === "condition") {
|
|
// 조건 노드: 재귀적으로 상위 노드 필드 수집 (통과 노드)
|
|
console.log("✅ [ConditionProperties] 조건 노드 통과 → 상위 탐색");
|
|
fields.push(...getAllSourceFields(sourceNode.id, visited));
|
|
} else if (
|
|
sourceNode.type === "insertAction" ||
|
|
sourceNode.type === "updateAction" ||
|
|
sourceNode.type === "deleteAction" ||
|
|
sourceNode.type === "upsertAction"
|
|
) {
|
|
// Action 노드: 재귀적으로 상위 노드 필드 수집
|
|
fields.push(...getAllSourceFields(sourceNode.id, visited));
|
|
} else {
|
|
// 기타 모든 노드: 재귀적으로 상위 노드 필드 수집 (통과 노드로 처리)
|
|
console.log(`✅ [ConditionProperties] 통과 노드 (${sourceNode.type}) → 상위 탐색`);
|
|
fields.push(...getAllSourceFields(sourceNode.id, visited));
|
|
}
|
|
}
|
|
|
|
// 중복 제거
|
|
const uniqueFields = Array.from(new Map(fields.map((f) => [f.name, f])).values());
|
|
return uniqueFields;
|
|
};
|
|
|
|
const fields = getAllSourceFields(nodeId);
|
|
console.log("✅ [ConditionProperties] 최종 수집된 필드:", fields);
|
|
console.log("🔍 [ConditionProperties] 현재 노드 ID:", nodeId);
|
|
console.log(
|
|
"🔍 [ConditionProperties] 연결된 엣지:",
|
|
edges.filter((e) => e.target === nodeId),
|
|
);
|
|
setAvailableFields(fields);
|
|
}, [nodeId, nodes, edges]);
|
|
|
|
const handleAddCondition = () => {
|
|
const newCondition = {
|
|
field: "",
|
|
operator: "EQUALS" as ConditionOperator,
|
|
value: "",
|
|
valueType: "static" as "static" | "field",
|
|
// EXISTS 연산자용 필드는 초기값 없음
|
|
lookupTable: undefined,
|
|
lookupTableLabel: undefined,
|
|
lookupField: undefined,
|
|
lookupFieldLabel: undefined,
|
|
};
|
|
setConditions([...conditions, newCondition]);
|
|
};
|
|
|
|
const handleRemoveCondition = (index: number) => {
|
|
const newConditions = conditions.filter((_, i) => i !== index);
|
|
setConditions(newConditions);
|
|
updateNode(nodeId, {
|
|
conditions: newConditions,
|
|
});
|
|
};
|
|
|
|
const handleDisplayNameChange = (newDisplayName: string) => {
|
|
setDisplayName(newDisplayName);
|
|
updateNode(nodeId, {
|
|
displayName: newDisplayName,
|
|
});
|
|
};
|
|
|
|
const handleConditionChange = async (index: number, field: string, value: any) => {
|
|
const newConditions = [...conditions];
|
|
newConditions[index] = { ...newConditions[index], [field]: value };
|
|
|
|
// EXISTS 연산자로 변경 시 테이블 목록 로드 및 기존 value/valueType 초기화
|
|
if (field === "operator" && isExistsOperator(value)) {
|
|
await ensureTablesLoaded();
|
|
// EXISTS 연산자에서는 value, valueType이 필요 없으므로 초기화
|
|
newConditions[index].value = "";
|
|
newConditions[index].valueType = undefined;
|
|
}
|
|
|
|
// EXISTS 연산자에서 다른 연산자로 변경 시 lookup 필드들 초기화
|
|
if (field === "operator" && !isExistsOperator(value)) {
|
|
newConditions[index].lookupTable = undefined;
|
|
newConditions[index].lookupTableLabel = undefined;
|
|
newConditions[index].lookupField = undefined;
|
|
newConditions[index].lookupFieldLabel = undefined;
|
|
}
|
|
|
|
// lookupTable 변경 시 컬럼 목록 로드 및 라벨 설정
|
|
if (field === "lookupTable" && value) {
|
|
const tableInfo = allTables.find((t) => t.tableName === value);
|
|
if (tableInfo) {
|
|
newConditions[index].lookupTableLabel = tableInfo.tableLabel;
|
|
}
|
|
// 테이블 변경 시 필드 초기화
|
|
newConditions[index].lookupField = undefined;
|
|
newConditions[index].lookupFieldLabel = undefined;
|
|
// 컬럼 목록 미리 로드
|
|
await loadTableColumns(value);
|
|
}
|
|
|
|
// lookupField 변경 시 라벨 설정
|
|
if (field === "lookupField" && value) {
|
|
const tableName = newConditions[index].lookupTable;
|
|
if (tableName && tableColumnsCache[tableName]) {
|
|
const columnInfo = tableColumnsCache[tableName].find((c) => c.columnName === value);
|
|
if (columnInfo) {
|
|
newConditions[index].lookupFieldLabel = columnInfo.columnLabel;
|
|
}
|
|
}
|
|
}
|
|
|
|
setConditions(newConditions);
|
|
updateNode(nodeId, {
|
|
conditions: newConditions,
|
|
});
|
|
};
|
|
|
|
const handleLogicChange = (newLogic: "AND" | "OR") => {
|
|
setLogic(newLogic);
|
|
updateNode(nodeId, {
|
|
logic: newLogic,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="space-y-4 p-4 pb-8">
|
|
{/* 기본 정보 */}
|
|
<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) => handleDisplayNameChange(e.target.value)}
|
|
className="mt-1"
|
|
placeholder="노드 표시 이름"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="logic" className="text-xs">
|
|
조건 로직
|
|
</Label>
|
|
<Select value={logic} onValueChange={handleLogicChange}>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="AND">AND (모두 충족)</SelectItem>
|
|
<SelectItem value="OR">OR (하나라도 충족)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 조건식 */}
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold">조건식</h3>
|
|
<Button size="sm" variant="outline" onClick={handleAddCondition} className="h-7">
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
{conditions.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{conditions.map((condition, index) => (
|
|
<div key={index} className="rounded border bg-yellow-50 p-3">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-medium text-yellow-700">조건 #{index + 1}</span>
|
|
{index > 0 && (
|
|
<span className="rounded bg-yellow-200 px-1.5 py-0.5 text-xs font-semibold text-yellow-800">
|
|
{logic}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<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">
|
|
<div>
|
|
<Label className="text-xs text-gray-600">필드명</Label>
|
|
{availableFields.length > 0 ? (
|
|
<Select
|
|
value={condition.field}
|
|
onValueChange={(value) => handleConditionChange(index, "field", value)}
|
|
>
|
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
|
<SelectValue placeholder="필드 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableFields.map((field) => (
|
|
<SelectItem key={field.name} value={field.name}>
|
|
{field.label || field.name}
|
|
{field.type && <span className="ml-2 text-xs text-gray-400">({field.type})</span>}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
|
소스 노드를 연결하세요
|
|
</div>
|
|
)}
|
|
</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>
|
|
|
|
{/* EXISTS 연산자인 경우: 테이블/필드 선택 UI (검색 가능한 Combobox) */}
|
|
{isExistsOperator(condition.operator) && (
|
|
<>
|
|
<div>
|
|
<Label className="text-xs text-gray-600">
|
|
<Database className="mr-1 inline h-3 w-3" />
|
|
조회할 테이블
|
|
</Label>
|
|
{loadingTables ? (
|
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
|
테이블 목록 로딩 중...
|
|
</div>
|
|
) : allTables.length > 0 ? (
|
|
<TableCombobox
|
|
tables={allTables}
|
|
value={(condition as any).lookupTable || ""}
|
|
onSelect={(value) => handleConditionChange(index, "lookupTable", value)}
|
|
placeholder="테이블 검색..."
|
|
/>
|
|
) : (
|
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
|
테이블 목록을 로드할 수 없습니다
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{(condition as any).lookupTable && (
|
|
<ColumnSelectSection
|
|
lookupTable={(condition as any).lookupTable}
|
|
lookupField={(condition as any).lookupField || ""}
|
|
tableColumnsCache={tableColumnsCache}
|
|
loadingColumns={loadingColumns}
|
|
loadTableColumns={loadTableColumns}
|
|
onSelect={(value) => handleConditionChange(index, "lookupField", value)}
|
|
/>
|
|
)}
|
|
|
|
<div className="rounded bg-purple-50 p-2 text-xs text-purple-700">
|
|
{condition.operator === "EXISTS_IN"
|
|
? `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하면 TRUE`
|
|
: `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하지 않으면 TRUE`}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* 일반 연산자인 경우: 기존 비교값 UI */}
|
|
{condition.operator !== "IS_NULL" &&
|
|
condition.operator !== "IS_NOT_NULL" &&
|
|
!isExistsOperator(condition.operator) && (
|
|
<>
|
|
<div>
|
|
<Label className="text-xs text-gray-600">비교 값 타입</Label>
|
|
<Select
|
|
value={(condition as any).valueType || "static"}
|
|
onValueChange={(value) => handleConditionChange(index, "valueType", value)}
|
|
>
|
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="static">고정값</SelectItem>
|
|
<SelectItem value="field">필드 참조</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs text-gray-600">
|
|
{(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
|
|
</Label>
|
|
{(condition as any).valueType === "field" ? (
|
|
// 필드 참조: 드롭다운으로 선택
|
|
availableFields.length > 0 ? (
|
|
<Select
|
|
value={condition.value as string}
|
|
onValueChange={(value) => handleConditionChange(index, "value", value)}
|
|
>
|
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
|
<SelectValue placeholder="비교할 필드 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableFields.map((field) => (
|
|
<SelectItem key={field.name} value={field.name}>
|
|
{field.label || field.name}
|
|
{field.type && (
|
|
<span className="ml-2 text-xs text-gray-400">({field.type})</span>
|
|
)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
|
소스 노드를 연결하세요
|
|
</div>
|
|
)
|
|
) : (
|
|
// 고정값: 직접 입력
|
|
<Input
|
|
value={condition.value as string}
|
|
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
|
placeholder="비교할 값"
|
|
className="mt-1 h-8 text-xs"
|
|
/>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
|
|
조건식이 없습니다. "추가" 버튼을 클릭하세요.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
|
|
{/* 안내 */}
|
|
<div className="space-y-2">
|
|
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
|
|
<strong>소스 노드 연결</strong>: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
|
|
</div>
|
|
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
|
<strong>비교 값 타입</strong>:<br />
|
|
- <strong>고정값</strong>: 직접 입력한 값과 비교 (예: age > 30)
|
|
<br />- <strong>필드 참조</strong>: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량)
|
|
</div>
|
|
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
|
<strong>테이블 존재 여부 검사</strong>:<br />
|
|
- <strong>다른 테이블에 존재함</strong>: 값이 다른 테이블에 있으면 TRUE
|
|
<br />- <strong>다른 테이블에 존재하지 않음</strong>: 값이 다른 테이블에 없으면 TRUE
|
|
<br />
|
|
(예: 품명이 품목정보 테이블에 없으면 자동 등록)
|
|
</div>
|
|
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
|
<strong>AND</strong>: 모든 조건이 참이어야 TRUE 출력
|
|
</div>
|
|
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
|
<strong>OR</strong>: 하나라도 참이면 TRUE 출력
|
|
</div>
|
|
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
|
TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|