노드 수정

This commit is contained in:
kjs 2025-10-08 09:39:13 +09:00
parent 258bd80201
commit e48cc4decc
8 changed files with 1000 additions and 18 deletions

View File

@ -1866,10 +1866,25 @@ export class NodeFlowExecutionService {
const results = conditions.map((condition: any) => {
const fieldValue = inputData[condition.field];
// 🔥 비교 값 타입 확인: "field" (필드 참조) 또는 "static" (고정값)
let compareValue = condition.value;
if (condition.valueType === "field") {
// 필드 참조: inputData에서 해당 필드의 값을 가져옴
compareValue = inputData[condition.value];
logger.info(
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
);
} else {
logger.info(
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
);
}
return this.evaluateCondition(
fieldValue,
condition.operator,
condition.value
compareValue
);
});
@ -1878,7 +1893,7 @@ export class NodeFlowExecutionService {
? results.some((r: boolean) => r)
: results.every((r: boolean) => r);
logger.info(`🔍 조건 평가 결과: ${result}`);
logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`);
return result;
}

View File

@ -14,6 +14,7 @@ import { PropertiesPanel } from "./panels/PropertiesPanel";
import { FlowToolbar } from "./FlowToolbar";
import { TableSourceNode } from "./nodes/TableSourceNode";
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
import { ReferenceLookupNode } from "./nodes/ReferenceLookupNode";
import { ConditionNode } from "./nodes/ConditionNode";
import { FieldMappingNode } from "./nodes/FieldMappingNode";
import { InsertActionNode } from "./nodes/InsertActionNode";
@ -31,6 +32,7 @@ const nodeTypes = {
tableSource: TableSourceNode,
externalDBSource: ExternalDBSourceNode,
restAPISource: RestAPISourceNode,
referenceLookup: ReferenceLookupNode,
// 변환/조건
condition: ConditionNode,
fieldMapping: FieldMappingNode,

View File

@ -0,0 +1,108 @@
"use client";
/**
* ( DB )
*
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Link2, Database } from "lucide-react";
import type { ReferenceLookupNodeData } from "@/types/node-editor";
export const ReferenceLookupNode = memo(({ data, selected }: NodeProps<ReferenceLookupNodeData>) => {
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-purple-500 shadow-lg" : "border-gray-200"
}`}
>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-purple-500 px-3 py-2 text-white">
<Link2 className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">{data.displayName || "참조 조회"}</div>
</div>
</div>
{/* 본문 */}
<div className="p-3">
<div className="mb-2 flex items-center gap-1 text-xs font-medium text-gray-500">
<Database className="h-3 w-3" />
DB
</div>
{/* 참조 테이블 */}
{data.referenceTable && (
<div className="mb-3 rounded bg-purple-50 p-2">
<div className="text-xs font-medium text-purple-700">📋 </div>
<div className="mt-1 font-mono text-xs text-purple-900">
{data.referenceTableLabel || data.referenceTable}
</div>
</div>
)}
{/* 조인 조건 */}
{data.joinConditions && data.joinConditions.length > 0 && (
<div className="mb-3">
<div className="text-xs font-medium text-gray-700">🔗 :</div>
<div className="mt-1 space-y-1">
{data.joinConditions.map((join, idx) => (
<div key={idx} className="text-xs text-gray-600">
<span className="font-medium">{join.sourceFieldLabel || join.sourceField}</span>
<span className="mx-1 text-purple-500"></span>
<span className="font-medium">{join.referenceFieldLabel || join.referenceField}</span>
</div>
))}
</div>
</div>
)}
{/* WHERE 조건 */}
{data.whereConditions && data.whereConditions.length > 0 && (
<div className="mb-3">
<div className="text-xs font-medium text-gray-700"> WHERE :</div>
<div className="mt-1 text-xs text-gray-600">{data.whereConditions.length} </div>
</div>
)}
{/* 출력 필드 */}
{data.outputFields && data.outputFields.length > 0 && (
<div>
<div className="text-xs font-medium text-gray-700">📤 :</div>
<div className="mt-1 max-h-[100px] space-y-1 overflow-y-auto">
{data.outputFields.slice(0, 3).map((field, idx) => (
<div key={idx} className="flex items-center gap-2 text-xs text-gray-600">
<div className="h-1.5 w-1.5 rounded-full bg-purple-400" />
<span className="font-medium">{field.alias}</span>
<span className="text-gray-400"> {field.fieldLabel || field.fieldName}</span>
</div>
))}
{data.outputFields.length > 3 && (
<div className="text-xs text-gray-400">... {data.outputFields.length - 3}</div>
)}
</div>
</div>
)}
</div>
{/* 입력 핸들 (왼쪽) */}
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white"
/>
{/* 출력 핸들 (오른쪽) */}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white"
/>
</div>
);
});
ReferenceLookupNode.displayName = "ReferenceLookupNode";

View File

@ -8,6 +8,7 @@ import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { TableSourceProperties } from "./properties/TableSourceProperties";
import { ReferenceLookupProperties } from "./properties/ReferenceLookupProperties";
import { InsertActionProperties } from "./properties/InsertActionProperties";
import { FieldMappingProperties } from "./properties/FieldMappingProperties";
import { ConditionProperties } from "./properties/ConditionProperties";
@ -77,6 +78,9 @@ function NodePropertiesRenderer({ node }: { node: any }) {
case "tableSource":
return <TableSourceProperties nodeId={node.id} data={node.data} />;
case "referenceLookup":
return <ReferenceLookupProperties nodeId={node.id} data={node.data} />;
case "insertAction":
return <InsertActionProperties nodeId={node.id} data={node.data} />;

View File

@ -14,6 +14,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import type { ConditionNodeData } from "@/types/node-editor";
// 필드 정의
interface FieldDefinition {
name: string;
label?: string;
type?: string;
}
interface ConditionPropertiesProps {
nodeId: string;
data: ConditionNodeData;
@ -35,11 +42,12 @@ const OPERATORS = [
] as const;
export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {
const { updateNode } = useFlowEditorStore();
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[]>([]);
// 데이터 변경 시 로컬 상태 업데이트
useEffect(() => {
@ -48,6 +56,98 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
setLogic(data.logic || "AND");
}, [data]);
// 🔥 연결된 소스 노드의 필드를 재귀적으로 수집
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 === "insertAction" ||
sourceNode.type === "updateAction" ||
sourceNode.type === "deleteAction" ||
sourceNode.type === "upsertAction"
) {
// Action 노드: 재귀적으로 상위 노드 필드 수집
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 = () => {
setConditions([
...conditions,
@ -55,6 +155,7 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
field: "",
operator: "EQUALS",
value: "",
valueType: "static", // "static" (고정값) 또는 "field" (필드 참조)
},
]);
};
@ -151,12 +252,28 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
<div className="space-y-2">
<div>
<Label className="text-xs text-gray-600"></Label>
<Input
value={condition.field}
onChange={(e) => handleConditionChange(index, "field", e.target.value)}
placeholder="조건을 검사할 필드"
className="mt-1 h-8 text-xs"
/>
{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>
@ -179,15 +296,62 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
</div>
{condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && (
<div>
<Label className="text-xs text-gray-600"> </Label>
<Input
value={condition.value as string}
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
placeholder="비교할 값"
className="mt-1 h-8 text-xs"
/>
</div>
<>
<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>
@ -209,6 +373,13 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
{/* 안내 */}
<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 &gt; 30)
<br /> <strong> </strong>: (: 주문수량 &gt; )
</div>
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
💡 <strong>AND</strong>: TRUE
</div>

View File

@ -0,0 +1,643 @@
"use client";
/**
*
*/
import { useEffect, useState, useCallback } from "react";
import { Plus, Trash2, Search } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import type { ReferenceLookupNodeData } from "@/types/node-editor";
import { tableTypeApi } from "@/lib/api/screen";
// 필드 정의
interface FieldDefinition {
name: string;
label?: string;
type?: string;
}
interface ReferenceLookupPropertiesProps {
nodeId: string;
data: ReferenceLookupNodeData;
}
const OPERATORS = [
{ value: "=", label: "같음 (=)" },
{ value: "!=", label: "같지 않음 (≠)" },
{ value: ">", label: "보다 큼 (>)" },
{ value: "<", label: "보다 작음 (<)" },
{ value: ">=", label: "크거나 같음 (≥)" },
{ value: "<=", label: "작거나 같음 (≤)" },
{ value: "LIKE", label: "포함 (LIKE)" },
{ value: "IN", label: "IN" },
] as const;
export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPropertiesProps) {
const { updateNode, nodes, edges } = useFlowEditorStore();
// 상태
const [displayName, setDisplayName] = useState(data.displayName || "참조 조회");
const [referenceTable, setReferenceTable] = useState(data.referenceTable || "");
const [referenceTableLabel, setReferenceTableLabel] = useState(data.referenceTableLabel || "");
const [joinConditions, setJoinConditions] = useState(data.joinConditions || []);
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
const [outputFields, setOutputFields] = useState(data.outputFields || []);
// 소스 필드 수집
const [sourceFields, setSourceFields] = useState<FieldDefinition[]>([]);
// 참조 테이블 관련
const [tables, setTables] = useState<any[]>([]);
const [tablesLoading, setTablesLoading] = useState(false);
const [tablesOpen, setTablesOpen] = useState(false);
const [referenceColumns, setReferenceColumns] = useState<FieldDefinition[]>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
// 데이터 변경 시 로컬 상태 동기화
useEffect(() => {
setDisplayName(data.displayName || "참조 조회");
setReferenceTable(data.referenceTable || "");
setReferenceTableLabel(data.referenceTableLabel || "");
setJoinConditions(data.joinConditions || []);
setWhereConditions(data.whereConditions || []);
setOutputFields(data.outputFields || []);
}, [data]);
// 🔍 소스 필드 수집 (업스트림 노드에서)
useEffect(() => {
const incomingEdges = edges.filter((e) => e.target === nodeId);
const fields: FieldDefinition[] = [];
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" && sourceData.fields) {
fields.push(...sourceData.fields);
} else if (sourceNode.type === "externalDBSource" && sourceData.outputFields) {
fields.push(...sourceData.outputFields);
}
}
setSourceFields(fields);
}, [nodeId, nodes, edges]);
// 📊 테이블 목록 로드
useEffect(() => {
loadTables();
}, []);
const loadTables = async () => {
setTablesLoading(true);
try {
const data = await tableTypeApi.getTables();
setTables(data);
} catch (error) {
console.error("테이블 로드 실패:", error);
} finally {
setTablesLoading(false);
}
};
// 📋 참조 테이블 컬럼 로드
useEffect(() => {
if (referenceTable) {
loadReferenceColumns();
} else {
setReferenceColumns([]);
}
}, [referenceTable]);
const loadReferenceColumns = async () => {
if (!referenceTable) return;
setColumnsLoading(true);
try {
const cols = await tableTypeApi.getColumns(referenceTable);
const formatted = cols.map((col: any) => ({
name: col.columnName,
type: col.dataType,
label: col.displayName || col.columnName,
}));
setReferenceColumns(formatted);
} catch (error) {
console.error("컬럼 로드 실패:", error);
setReferenceColumns([]);
} finally {
setColumnsLoading(false);
}
};
// 테이블 선택 핸들러
const handleTableSelect = (tableName: string) => {
const selectedTable = tables.find((t) => t.tableName === tableName);
if (selectedTable) {
setReferenceTable(tableName);
setReferenceTableLabel(selectedTable.label);
setTablesOpen(false);
// 기존 설정 초기화
setJoinConditions([]);
setWhereConditions([]);
setOutputFields([]);
}
};
// 조인 조건 추가
const handleAddJoinCondition = () => {
setJoinConditions([
...joinConditions,
{
sourceField: "",
referenceField: "",
},
]);
};
const handleRemoveJoinCondition = (index: number) => {
setJoinConditions(joinConditions.filter((_, i) => i !== index));
};
const handleJoinConditionChange = (index: number, field: string, value: any) => {
const newConditions = [...joinConditions];
newConditions[index] = { ...newConditions[index], [field]: value };
// 라벨도 함께 저장
if (field === "sourceField") {
const sourceField = sourceFields.find((f) => f.name === value);
newConditions[index].sourceFieldLabel = sourceField?.label || value;
} else if (field === "referenceField") {
const refField = referenceColumns.find((f) => f.name === value);
newConditions[index].referenceFieldLabel = refField?.label || value;
}
setJoinConditions(newConditions);
};
// WHERE 조건 추가
const handleAddWhereCondition = () => {
setWhereConditions([
...whereConditions,
{
field: "",
operator: "=",
value: "",
valueType: "static",
},
]);
};
const handleRemoveWhereCondition = (index: number) => {
setWhereConditions(whereConditions.filter((_, i) => i !== index));
};
const handleWhereConditionChange = (index: number, field: string, value: any) => {
const newConditions = [...whereConditions];
newConditions[index] = { ...newConditions[index], [field]: value };
// 라벨도 함께 저장
if (field === "field") {
const refField = referenceColumns.find((f) => f.name === value);
newConditions[index].fieldLabel = refField?.label || value;
}
setWhereConditions(newConditions);
};
// 출력 필드 추가
const handleAddOutputField = () => {
setOutputFields([
...outputFields,
{
fieldName: "",
alias: "",
},
]);
};
const handleRemoveOutputField = (index: number) => {
setOutputFields(outputFields.filter((_, i) => i !== index));
};
const handleOutputFieldChange = (index: number, field: string, value: any) => {
const newFields = [...outputFields];
newFields[index] = { ...newFields[index], [field]: value };
// 라벨도 함께 저장
if (field === "fieldName") {
const refField = referenceColumns.find((f) => f.name === value);
newFields[index].fieldLabel = refField?.label || value;
// alias 자동 설정
if (!newFields[index].alias) {
newFields[index].alias = `ref_${value}`;
}
}
setOutputFields(newFields);
};
const handleSave = () => {
updateNode(nodeId, {
displayName,
referenceTable,
referenceTableLabel,
joinConditions,
whereConditions,
outputFields,
});
};
const selectedTableLabel = tables.find((t) => t.tableName === referenceTable)?.label || referenceTable;
return (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
{/* 기본 정보 */}
<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"
placeholder="노드 표시 이름"
/>
</div>
{/* 참조 테이블 선택 */}
<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>
) : referenceTable ? (
<span className="truncate">{selectedTableLabel}</span>
) : (
<span className="text-muted-foreground"> ...</span>
)}
<Search className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-9" />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
<ScrollArea className="h-[300px]">
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.label} ${table.tableName} ${table.description}`}
onSelect={() => handleTableSelect(table.tableName)}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
referenceTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.label}</span>
{table.label !== table.tableName && (
<span className="text-muted-foreground text-xs">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</ScrollArea>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</div>
{/* 조인 조건 */}
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold"> (FK )</h3>
<Button
size="sm"
variant="outline"
onClick={handleAddJoinCondition}
className="h-7"
disabled={!referenceTable}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{joinConditions.length > 0 ? (
<div className="space-y-2">
{joinConditions.map((condition, index) => (
<div key={index} className="rounded border bg-purple-50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-purple-700"> #{index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveJoinCondition(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>
<Select
value={condition.sourceField}
onValueChange={(value) => handleJoinConditionChange(index, "sourceField", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="소스 필드 선택" />
</SelectTrigger>
<SelectContent>
{sourceFields.map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={condition.referenceField}
onValueChange={(value) => handleJoinConditionChange(index, "referenceField", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="참조 필드 선택" />
</SelectTrigger>
<SelectContent>
{referenceColumns.map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
))}
</div>
) : (
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
()
</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={handleAddWhereCondition}
className="h-7"
disabled={!referenceTable}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{whereConditions.length > 0 && (
<div className="space-y-2">
{whereConditions.map((condition, index) => (
<div key={index} className="rounded border bg-yellow-50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-yellow-700">WHERE #{index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveWhereCondition(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>
<Select
value={condition.field}
onValueChange={(value) => handleWhereConditionChange(index, "field", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{referenceColumns.map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-gray-600"></Label>
<Select
value={condition.operator}
onValueChange={(value) => handleWhereConditionChange(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>
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={condition.valueType || "static"}
onValueChange={(value) => handleWhereConditionChange(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.valueType === "field" ? "소스 필드" : "값"}
</Label>
{condition.valueType === "field" ? (
<Select
value={condition.value}
onValueChange={(value) => handleWhereConditionChange(index, "value", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="소스 필드 선택" />
</SelectTrigger>
<SelectContent>
{sourceFields.map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={condition.value}
onChange={(e) => handleWhereConditionChange(index, "value", e.target.value)}
placeholder="비교할 값"
className="mt-1 h-8 text-xs"
/>
)}
</div>
</div>
</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={handleAddOutputField}
className="h-7"
disabled={!referenceTable}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{outputFields.length > 0 ? (
<div className="space-y-2">
{outputFields.map((field, index) => (
<div key={index} className="rounded border bg-blue-50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-blue-700"> #{index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveOutputField(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>
<Select
value={field.fieldName}
onValueChange={(value) => handleOutputFieldChange(index, "fieldName", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{referenceColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-gray-600"> (Alias)</Label>
<Input
value={field.alias}
onChange={(e) => handleOutputFieldChange(index, "alias", e.target.value)}
placeholder="ref_field_name"
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="flex gap-2">
<Button onClick={handleSave} className="flex-1" size="sm">
</Button>
</div>
{/* 안내 */}
<div className="space-y-2">
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
🔗 <strong> </strong>: (: customer_id id)
</div>
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
<strong>WHERE </strong>: (: grade = 'VIP')
</div>
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
📤 <strong> </strong>: ( )
</div>
</div>
</div>
</ScrollArea>
);
}

View File

@ -32,6 +32,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [
category: "source",
color: "#10B981", // 초록색
},
{
type: "referenceLookup",
label: "참조 조회",
icon: "🔗",
description: "다른 테이블에서 데이터를 조회합니다 (내부 DB 전용)",
category: "source",
color: "#A855F7", // 보라색
},
// ========================================================================
// 변환/조건

View File

@ -12,6 +12,7 @@ export type NodeType =
| "tableSource" // 테이블 소스
| "externalDBSource" // 외부 DB 소스
| "restAPISource" // REST API 소스
| "referenceLookup" // 참조 테이블 조회 (내부 DB 전용)
| "condition" // 조건 분기
| "fieldMapping" // 필드 매핑
| "dataTransform" // 데이터 변환
@ -91,6 +92,35 @@ export interface RestAPISourceNodeData {
displayName?: string;
}
// 참조 테이블 조회 노드 (내부 DB 전용)
export interface ReferenceLookupNodeData {
type: "referenceLookup";
referenceTable: string; // 참조할 테이블명
referenceTableLabel?: string; // 테이블 라벨
joinConditions: Array<{
// 조인 조건 (FK 매핑)
sourceField: string; // 소스 데이터의 필드
sourceFieldLabel?: string;
referenceField: string; // 참조 테이블의 필드
referenceFieldLabel?: string;
}>;
whereConditions?: Array<{
// 추가 WHERE 조건
field: string;
fieldLabel?: string;
operator: string;
value: any;
valueType?: "static" | "field"; // 고정값 또는 소스 필드 참조
}>;
outputFields: Array<{
// 가져올 필드들
fieldName: string; // 참조 테이블의 컬럼명
fieldLabel?: string;
alias: string; // 결과 데이터에서 사용할 이름
}>;
displayName?: string;
}
// 조건 분기 노드
export interface ConditionNodeData {
conditions: Array<{
@ -373,6 +403,7 @@ export type NodeData =
| TableSourceNodeData
| ExternalDBSourceNodeData
| RestAPISourceNodeData
| ReferenceLookupNodeData
| ConditionNodeData
| FieldMappingNodeData
| DataTransformNodeData