ConnectionSetupModal 리팩터링
This commit is contained in:
parent
4ccce97eef
commit
7acea0b272
File diff suppressed because it is too large
Load Diff
|
|
@ -75,25 +75,149 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
|
||||||
setEditingRelationshipId,
|
setEditingRelationshipId,
|
||||||
} = useDataFlowDesigner();
|
} = useDataFlowDesigner();
|
||||||
|
|
||||||
// 편집 모드일 때 관계도 이름 로드
|
// 컬럼 클릭 처리 비활성화 (테이블 노드 선택 방식으로 변경)
|
||||||
|
const handleColumnClick = useCallback((tableName: string, columnName: string) => {
|
||||||
|
// 컬럼 클릭으로는 더 이상 선택하지 않음
|
||||||
|
console.log(`컬럼 클릭 무시됨: ${tableName}.${columnName}`);
|
||||||
|
return;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 편집 모드일 때 관계도 데이터 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDiagramName = async () => {
|
const loadDiagramData = async () => {
|
||||||
if (diagramId && diagramId > 0) {
|
if (diagramId && diagramId > 0) {
|
||||||
try {
|
try {
|
||||||
const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(diagramId, companyCode);
|
const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(diagramId, companyCode);
|
||||||
if (jsonDiagram && jsonDiagram.diagram_name) {
|
if (jsonDiagram) {
|
||||||
setCurrentDiagramName(jsonDiagram.diagram_name);
|
// 관계도 이름 설정
|
||||||
|
if (jsonDiagram.diagram_name) {
|
||||||
|
setCurrentDiagramName(jsonDiagram.diagram_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관계 데이터 로드
|
||||||
|
if (jsonDiagram.relationships?.relationships && Array.isArray(jsonDiagram.relationships.relationships)) {
|
||||||
|
const loadedRelationships = jsonDiagram.relationships.relationships.map((rel) => ({
|
||||||
|
id: rel.id || `rel-${Date.now()}-${Math.random()}`,
|
||||||
|
fromTable: rel.fromTable,
|
||||||
|
toTable: rel.toTable,
|
||||||
|
fromColumns: Array.isArray(rel.fromColumns) ? rel.fromColumns : [],
|
||||||
|
toColumns: Array.isArray(rel.toColumns) ? rel.toColumns : [],
|
||||||
|
connectionType: rel.connectionType || "simple-key",
|
||||||
|
relationshipName: rel.relationshipName || "",
|
||||||
|
settings: rel.settings || {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTempRelationships(loadedRelationships);
|
||||||
|
|
||||||
|
// 관계 데이터로부터 테이블 노드들을 생성
|
||||||
|
const tableNames = new Set<string>();
|
||||||
|
loadedRelationships.forEach((rel) => {
|
||||||
|
tableNames.add(rel.fromTable);
|
||||||
|
tableNames.add(rel.toTable);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 각 테이블의 정보를 API에서 가져와서 노드 생성
|
||||||
|
const loadedNodes = await Promise.all(
|
||||||
|
Array.from(tableNames).map(async (tableName) => {
|
||||||
|
try {
|
||||||
|
const columns = await DataFlowAPI.getTableColumns(tableName);
|
||||||
|
return {
|
||||||
|
id: `table-${tableName}`,
|
||||||
|
type: "tableNode",
|
||||||
|
position: jsonDiagram.node_positions?.[tableName] || {
|
||||||
|
x: Math.random() * 300,
|
||||||
|
y: Math.random() * 200,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
table: {
|
||||||
|
tableName,
|
||||||
|
displayName: tableName,
|
||||||
|
description: "",
|
||||||
|
columns: Array.isArray(columns)
|
||||||
|
? columns.map((col) => ({
|
||||||
|
name: col.columnName || "unknown",
|
||||||
|
type: col.dataType || "varchar",
|
||||||
|
description: col.description || "",
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
onColumnClick: handleColumnClick,
|
||||||
|
selectedColumns: [],
|
||||||
|
connectedColumns: {},
|
||||||
|
},
|
||||||
|
selected: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`테이블 ${tableName} 정보 로드 실패:`, error);
|
||||||
|
return {
|
||||||
|
id: `table-${tableName}`,
|
||||||
|
type: "tableNode",
|
||||||
|
position: jsonDiagram.node_positions?.[tableName] || {
|
||||||
|
x: Math.random() * 300,
|
||||||
|
y: Math.random() * 200,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
table: {
|
||||||
|
tableName,
|
||||||
|
displayName: tableName,
|
||||||
|
description: "",
|
||||||
|
columns: [],
|
||||||
|
},
|
||||||
|
onColumnClick: handleColumnClick,
|
||||||
|
selectedColumns: [],
|
||||||
|
connectedColumns: {},
|
||||||
|
},
|
||||||
|
selected: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
setNodes(loadedNodes);
|
||||||
|
|
||||||
|
// 관계 데이터로부터 엣지 생성
|
||||||
|
const loadedEdges = loadedRelationships.map((rel) => ({
|
||||||
|
id: `edge-${rel.fromTable}-${rel.toTable}-${rel.id}`,
|
||||||
|
source: `table-${rel.fromTable}`,
|
||||||
|
target: `table-${rel.toTable}`,
|
||||||
|
type: "step",
|
||||||
|
data: {
|
||||||
|
relationshipId: rel.id,
|
||||||
|
fromTable: rel.fromTable,
|
||||||
|
toTable: rel.toTable,
|
||||||
|
connectionType: rel.connectionType,
|
||||||
|
relationshipName: rel.relationshipName,
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
stroke: "#3b82f6",
|
||||||
|
strokeWidth: 2,
|
||||||
|
},
|
||||||
|
animated: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setEdges(loadedEdges);
|
||||||
|
|
||||||
|
console.log("✅ 관계도 데이터 로드 완료:", {
|
||||||
|
relationships: jsonDiagram.relationships?.relationships?.length || 0,
|
||||||
|
tables: Array.from(new Set(loadedRelationships.flatMap((rel) => [rel.fromTable, rel.toTable]))).length,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("관계도 이름 로드 실패:", error);
|
console.error("관계도 데이터 로드 실패:", error);
|
||||||
|
toast.error("관계도를 불러오는데 실패했습니다.");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setCurrentDiagramName(""); // 신규 생성 모드
|
// 신규 생성 모드
|
||||||
|
setCurrentDiagramName("");
|
||||||
|
setNodes([]);
|
||||||
|
setEdges([]);
|
||||||
|
setTempRelationships([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadDiagramName();
|
loadDiagramData();
|
||||||
}, [diagramId, companyCode, setCurrentDiagramName]);
|
}, [diagramId, companyCode, setCurrentDiagramName, setNodes, setEdges, setTempRelationships, handleColumnClick]);
|
||||||
|
|
||||||
// 키보드 이벤트 핸들러 (Del 키로 선택된 노드 삭제)
|
// 키보드 이벤트 핸들러 (Del 키로 선택된 노드 삭제)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -126,13 +250,6 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [selectedNodes, setNodes, setSelectedColumns, setSelectedNodes]);
|
}, [selectedNodes, setNodes, setSelectedColumns, setSelectedNodes]);
|
||||||
|
|
||||||
// 컬럼 클릭 처리 비활성화 (테이블 노드 선택 방식으로 변경)
|
|
||||||
const handleColumnClick = useCallback((tableName: string, columnName: string) => {
|
|
||||||
// 컬럼 클릭으로는 더 이상 선택하지 않음
|
|
||||||
console.log(`컬럼 클릭 무시됨: ${tableName}.${columnName}`);
|
|
||||||
return;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 현재 추가된 테이블명 목록 가져오기
|
// 현재 추가된 테이블명 목록 가져오기
|
||||||
const getSelectedTableNames = useCallback(() => {
|
const getSelectedTableNames = useCallback(() => {
|
||||||
return extractTableNames(nodes);
|
return extractTableNames(nodes);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { ConditionNode, ColumnInfo } from "@/lib/api/dataflow";
|
||||||
|
import { getInputTypeForDataType } from "@/utils/connectionUtils";
|
||||||
|
|
||||||
|
interface ConditionRendererProps {
|
||||||
|
conditions: ConditionNode[];
|
||||||
|
fromTableColumns: ColumnInfo[];
|
||||||
|
onUpdateCondition: (index: number, field: keyof ConditionNode, value: string) => void;
|
||||||
|
onRemoveCondition: (index: number) => void;
|
||||||
|
getCurrentGroupLevel: (index: number) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConditionRenderer: React.FC<ConditionRendererProps> = ({
|
||||||
|
conditions,
|
||||||
|
fromTableColumns,
|
||||||
|
onUpdateCondition,
|
||||||
|
onRemoveCondition,
|
||||||
|
getCurrentGroupLevel,
|
||||||
|
}) => {
|
||||||
|
const renderConditionValue = (condition: ConditionNode, index: number) => {
|
||||||
|
const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field);
|
||||||
|
const dataType = selectedColumn?.dataType?.toLowerCase() || "string";
|
||||||
|
const inputType = getInputTypeForDataType(dataType);
|
||||||
|
|
||||||
|
if (dataType.includes("bool")) {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={String(condition.value || "")}
|
||||||
|
onValueChange={(value) => onUpdateCondition(index, "value", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">TRUE</SelectItem>
|
||||||
|
<SelectItem value="false">FALSE</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type={inputType}
|
||||||
|
placeholder={inputType === "number" ? "숫자" : "값"}
|
||||||
|
value={String(condition.value || "")}
|
||||||
|
onChange={(e) => onUpdateCondition(index, "value", e.target.value)}
|
||||||
|
className="h-8 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{conditions.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed p-3 text-center text-xs text-gray-500">
|
||||||
|
조건을 추가하면 해당 조건을 만족할 때만 실행됩니다.
|
||||||
|
<br />
|
||||||
|
조건이 없으면 항상 실행됩니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<React.Fragment key="conditions-list">
|
||||||
|
{conditions.map((condition, index) => {
|
||||||
|
// 그룹 시작 렌더링
|
||||||
|
if (condition.type === "group-start") {
|
||||||
|
return (
|
||||||
|
<div key={condition.id} className="flex items-center gap-2">
|
||||||
|
{/* 그룹 시작 앞의 논리 연산자 - 이전 요소가 group-end가 아닌 경우에만 표시 */}
|
||||||
|
{index > 0 && conditions[index - 1]?.type !== "group-end" && (
|
||||||
|
<Select
|
||||||
|
value={condition.logicalOperator || "AND"}
|
||||||
|
onValueChange={(value: "AND" | "OR") => onUpdateCondition(index, "logicalOperator", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="AND">AND</SelectItem>
|
||||||
|
<SelectItem value="OR">OR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{/* 그룹 레벨에 따른 들여쓰기 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 p-2"
|
||||||
|
style={{ marginLeft: `${(condition.groupLevel || 0) * 20}px` }}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm text-blue-600">(</span>
|
||||||
|
<span className="text-xs text-blue-600">그룹 시작</span>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onRemoveCondition(index)} className="h-6 w-6 p-0">
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 끝 렌더링
|
||||||
|
if (condition.type === "group-end") {
|
||||||
|
return (
|
||||||
|
<div key={condition.id} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 p-2"
|
||||||
|
style={{ marginLeft: `${(condition.groupLevel || 0) * 20}px` }}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm text-blue-600">)</span>
|
||||||
|
<span className="text-xs text-blue-600">그룹 끝</span>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onRemoveCondition(index)} className="h-6 w-6 p-0">
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹 끝 다음에 다른 조건이나 그룹이 있으면 논리 연산자 표시 */}
|
||||||
|
{index < conditions.length - 1 && (
|
||||||
|
<Select
|
||||||
|
value={conditions[index + 1]?.logicalOperator || "AND"}
|
||||||
|
onValueChange={(value: "AND" | "OR") => onUpdateCondition(index + 1, "logicalOperator", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="AND">AND</SelectItem>
|
||||||
|
<SelectItem value="OR">OR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일반 조건 렌더링
|
||||||
|
return (
|
||||||
|
<div key={condition.id} className="flex items-center gap-2">
|
||||||
|
{/* 일반 조건 앞의 논리 연산자 - 이전 요소가 group-end가 아닌 경우에만 표시 */}
|
||||||
|
{index > 0 &&
|
||||||
|
conditions[index - 1]?.type !== "group-start" &&
|
||||||
|
conditions[index - 1]?.type !== "group-end" && (
|
||||||
|
<Select
|
||||||
|
value={condition.logicalOperator || "AND"}
|
||||||
|
onValueChange={(value: "AND" | "OR") => onUpdateCondition(index, "logicalOperator", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="AND">AND</SelectItem>
|
||||||
|
<SelectItem value="OR">OR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 그룹 레벨에 따른 들여쓰기와 조건 필드들 */}
|
||||||
|
<div
|
||||||
|
className="flex flex-1 items-center gap-2 rounded border bg-white p-2"
|
||||||
|
style={{ marginLeft: `${getCurrentGroupLevel(index) * 20}px` }}
|
||||||
|
>
|
||||||
|
{/* 조건 필드 선택 */}
|
||||||
|
<Select
|
||||||
|
value={condition.field || ""}
|
||||||
|
onValueChange={(value) => onUpdateCondition(index, "field", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fromTableColumns.map((column) => (
|
||||||
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
|
{column.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 연산자 선택 */}
|
||||||
|
<Select
|
||||||
|
value={condition.operator || "="}
|
||||||
|
onValueChange={(value) => onUpdateCondition(index, "operator", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-20 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="=">=</SelectItem>
|
||||||
|
<SelectItem value="!=">!=</SelectItem>
|
||||||
|
<SelectItem value=">">></SelectItem>
|
||||||
|
<SelectItem value="<"><</SelectItem>
|
||||||
|
<SelectItem value=">=">>=</SelectItem>
|
||||||
|
<SelectItem value="<="><=</SelectItem>
|
||||||
|
<SelectItem value="LIKE">LIKE</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 값 입력 */}
|
||||||
|
{renderConditionValue(condition, index)}
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onRemoveCondition(index)} className="h-8 w-8 p-0">
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Plus, Zap } from "lucide-react";
|
||||||
|
import { ConditionNode, ColumnInfo } from "@/lib/api/dataflow";
|
||||||
|
import { ConditionRenderer } from "./ConditionRenderer";
|
||||||
|
|
||||||
|
interface ConditionalSettingsProps {
|
||||||
|
conditions: ConditionNode[];
|
||||||
|
fromTableColumns: ColumnInfo[];
|
||||||
|
onAddCondition: () => void;
|
||||||
|
onAddGroupStart: () => void;
|
||||||
|
onAddGroupEnd: () => void;
|
||||||
|
onUpdateCondition: (index: number, field: keyof ConditionNode, value: string) => void;
|
||||||
|
onRemoveCondition: (index: number) => void;
|
||||||
|
getCurrentGroupLevel: (index: number) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConditionalSettings: React.FC<ConditionalSettingsProps> = ({
|
||||||
|
conditions,
|
||||||
|
fromTableColumns,
|
||||||
|
onAddCondition,
|
||||||
|
onAddGroupStart,
|
||||||
|
onAddGroupEnd,
|
||||||
|
onUpdateCondition,
|
||||||
|
onRemoveCondition,
|
||||||
|
getCurrentGroupLevel,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-l-4 border-l-purple-500 bg-purple-50/30 p-4">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<Zap className="h-4 w-4 text-purple-500" />
|
||||||
|
<span className="text-sm font-medium">전체 실행 조건 (언제 이 연결이 동작할지)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 실행 조건 설정 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-medium">실행 조건</Label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="outline" onClick={onAddCondition} className="h-7 text-xs">
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
조건 추가
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={onAddGroupStart} className="h-7 text-xs">
|
||||||
|
그룹 시작 (
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={onAddGroupEnd} className="h-7 text-xs">
|
||||||
|
그룹 끝 )
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조건 목록 */}
|
||||||
|
<ConditionRenderer
|
||||||
|
conditions={conditions}
|
||||||
|
fromTableColumns={fromTableColumns}
|
||||||
|
onUpdateCondition={onUpdateCondition}
|
||||||
|
onRemoveCondition={onRemoveCondition}
|
||||||
|
getCurrentGroupLevel={getCurrentGroupLevel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { ConditionNode, ColumnInfo } from "@/lib/api/dataflow";
|
||||||
|
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||||
|
import { getInputTypeForDataType } from "@/utils/connectionUtils";
|
||||||
|
|
||||||
|
interface ActionConditionRendererProps {
|
||||||
|
condition: ConditionNode;
|
||||||
|
condIndex: number;
|
||||||
|
actionIndex: number;
|
||||||
|
settings: DataSaveSettings;
|
||||||
|
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||||
|
fromTableColumns: ColumnInfo[];
|
||||||
|
getActionCurrentGroupLevel: (conditions: ConditionNode[], conditionIndex: number) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActionConditionRenderer: React.FC<ActionConditionRendererProps> = ({
|
||||||
|
condition,
|
||||||
|
condIndex,
|
||||||
|
actionIndex,
|
||||||
|
settings,
|
||||||
|
onSettingsChange,
|
||||||
|
fromTableColumns,
|
||||||
|
getActionCurrentGroupLevel,
|
||||||
|
}) => {
|
||||||
|
const removeConditionGroup = (groupId: string) => {
|
||||||
|
const newActions = [...settings.actions];
|
||||||
|
newActions[actionIndex].conditions = newActions[actionIndex].conditions!.filter((c) => c.groupId !== groupId);
|
||||||
|
onSettingsChange({ ...settings, actions: newActions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCondition = () => {
|
||||||
|
const newActions = [...settings.actions];
|
||||||
|
newActions[actionIndex].conditions = newActions[actionIndex].conditions!.filter((_, i) => i !== condIndex);
|
||||||
|
onSettingsChange({ ...settings, actions: newActions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCondition = (field: string, value: any) => {
|
||||||
|
const newActions = [...settings.actions];
|
||||||
|
(newActions[actionIndex].conditions![condIndex] as any)[field] = value;
|
||||||
|
onSettingsChange({ ...settings, actions: newActions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLogicalOperator = (targetIndex: number, value: "AND" | "OR") => {
|
||||||
|
const newActions = [...settings.actions];
|
||||||
|
newActions[actionIndex].conditions![targetIndex].logicalOperator = value;
|
||||||
|
onSettingsChange({ ...settings, actions: newActions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderConditionValue = () => {
|
||||||
|
const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field);
|
||||||
|
const dataType = selectedColumn?.dataType?.toLowerCase() || "string";
|
||||||
|
const inputType = getInputTypeForDataType(dataType);
|
||||||
|
|
||||||
|
if (dataType.includes("bool")) {
|
||||||
|
return (
|
||||||
|
<Select value={String(condition.value || "")} onValueChange={(value) => updateCondition("value", value)}>
|
||||||
|
<SelectTrigger className="h-6 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">TRUE</SelectItem>
|
||||||
|
<SelectItem value="false">FALSE</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type={inputType}
|
||||||
|
placeholder={inputType === "number" ? "숫자" : "값"}
|
||||||
|
value={String(condition.value || "")}
|
||||||
|
onChange={(e) => updateCondition("value", e.target.value)}
|
||||||
|
className="h-6 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그룹 시작 렌더링
|
||||||
|
if (condition.type === "group-start") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 그룹 시작 앞의 논리 연산자 */}
|
||||||
|
{condIndex > 0 && (
|
||||||
|
<Select
|
||||||
|
value={settings.actions[actionIndex].conditions![condIndex - 1]?.logicalOperator || "AND"}
|
||||||
|
onValueChange={(value: "AND" | "OR") => updateLogicalOperator(condIndex - 1, value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-24 border-green-200 bg-green-50 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="AND">AND</SelectItem>
|
||||||
|
<SelectItem value="OR">OR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 rounded border-2 border-dashed border-green-300 bg-green-50/50 p-1"
|
||||||
|
style={{ marginLeft: `${(condition.groupLevel || 0) * 15}px` }}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs text-green-600">(</span>
|
||||||
|
<span className="text-xs text-green-600">그룹 시작</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => removeConditionGroup(condition.groupId!)}
|
||||||
|
className="h-4 w-4 p-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-2 w-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 끝 렌더링
|
||||||
|
if (condition.type === "group-end") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 rounded border-2 border-dashed border-green-300 bg-green-50/50 p-1"
|
||||||
|
style={{ marginLeft: `${(condition.groupLevel || 0) * 15}px` }}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs text-green-600">)</span>
|
||||||
|
<span className="text-xs text-green-600">그룹 끝</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => removeConditionGroup(condition.groupId!)}
|
||||||
|
className="h-4 w-4 p-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-2 w-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일반 조건 렌더링
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 그룹 내 첫 번째 조건이 아닐 때만 논리 연산자 표시 */}
|
||||||
|
{condIndex > 0 && settings.actions[actionIndex].conditions![condIndex - 1]?.type !== "group-start" && (
|
||||||
|
<Select
|
||||||
|
value={settings.actions[actionIndex].conditions![condIndex - 1]?.logicalOperator || "AND"}
|
||||||
|
onValueChange={(value: "AND" | "OR") => updateLogicalOperator(condIndex - 1, value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-24 border-green-200 bg-green-50 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="AND">AND</SelectItem>
|
||||||
|
<SelectItem value="OR">OR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="flex flex-1 items-center gap-2 rounded border bg-white p-1"
|
||||||
|
style={{
|
||||||
|
marginLeft: `${getActionCurrentGroupLevel(settings.actions[actionIndex].conditions || [], condIndex) * 15}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select value={condition.field || ""} onValueChange={(value) => updateCondition("field", value)}>
|
||||||
|
<SelectTrigger className="h-6 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="필드" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fromTableColumns.map((column) => (
|
||||||
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
|
{column.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={condition.operator || "="}
|
||||||
|
onValueChange={(value: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE") => updateCondition("operator", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-20 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="=">=</SelectItem>
|
||||||
|
<SelectItem value="!=">!=</SelectItem>
|
||||||
|
<SelectItem value=">">></SelectItem>
|
||||||
|
<SelectItem value="<"><</SelectItem>
|
||||||
|
<SelectItem value=">=">>=</SelectItem>
|
||||||
|
<SelectItem value="<="><=</SelectItem>
|
||||||
|
<SelectItem value="LIKE">LIKE</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{renderConditionValue()}
|
||||||
|
<Button size="sm" variant="ghost" onClick={removeCondition} className="h-6 w-6 p-0">
|
||||||
|
<Trash2 className="h-2 w-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { ColumnInfo } from "@/lib/api/dataflow";
|
||||||
|
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||||
|
import { generateConditionId } from "@/utils/connectionUtils";
|
||||||
|
import { useActionConditionHelpers } from "@/hooks/useConditionManager";
|
||||||
|
import { ActionConditionRenderer } from "./ActionConditionRenderer";
|
||||||
|
|
||||||
|
interface ActionConditionsSectionProps {
|
||||||
|
action: DataSaveSettings["actions"][0];
|
||||||
|
actionIndex: number;
|
||||||
|
settings: DataSaveSettings;
|
||||||
|
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||||
|
fromTableColumns: ColumnInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = ({
|
||||||
|
action,
|
||||||
|
actionIndex,
|
||||||
|
settings,
|
||||||
|
onSettingsChange,
|
||||||
|
fromTableColumns,
|
||||||
|
}) => {
|
||||||
|
const { addActionGroupStart, addActionGroupEnd, getActionCurrentGroupLevel } = useActionConditionHelpers();
|
||||||
|
|
||||||
|
const addActionCondition = () => {
|
||||||
|
const newActions = [...settings.actions];
|
||||||
|
if (!newActions[actionIndex].conditions) {
|
||||||
|
newActions[actionIndex].conditions = [];
|
||||||
|
}
|
||||||
|
const currentConditions = newActions[actionIndex].conditions || [];
|
||||||
|
const newCondition = {
|
||||||
|
id: generateConditionId(),
|
||||||
|
type: "condition" as const,
|
||||||
|
field: "",
|
||||||
|
operator: "=" as const,
|
||||||
|
value: "",
|
||||||
|
dataType: "string",
|
||||||
|
// 첫 번째 조건이 아니고, 바로 앞이 group-start가 아니면 logicalOperator 추가
|
||||||
|
...(currentConditions.length > 0 &&
|
||||||
|
currentConditions[currentConditions.length - 1]?.type !== "group-start" && {
|
||||||
|
logicalOperator: "AND" as const,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
newActions[actionIndex].conditions = [...currentConditions, newCondition];
|
||||||
|
onSettingsChange({ ...settings, actions: newActions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAllConditions = () => {
|
||||||
|
const newActions = [...settings.actions];
|
||||||
|
newActions[actionIndex].conditions = [];
|
||||||
|
onSettingsChange({ ...settings, actions: newActions });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
<details className="group">
|
||||||
|
<summary className="flex cursor-pointer items-center justify-between rounded border p-2 text-xs font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
🔍 이 액션의 실행 조건 (선택사항)
|
||||||
|
{action.conditions && action.conditions.length > 0 && (
|
||||||
|
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
|
||||||
|
{action.conditions.length}개
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action.conditions && action.conditions.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
clearAllConditions();
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||||
|
title="조건 모두 삭제"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-2 border-l-2 border-gray-100 pl-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="outline" onClick={addActionCondition} className="h-6 text-xs">
|
||||||
|
<Plus className="mr-1 h-2 w-2" />
|
||||||
|
조건 추가
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => addActionGroupStart(actionIndex, settings, onSettingsChange)}
|
||||||
|
className="h-6 text-xs"
|
||||||
|
>
|
||||||
|
그룹 시작 (
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => addActionGroupEnd(actionIndex, settings, onSettingsChange)}
|
||||||
|
className="h-6 text-xs"
|
||||||
|
>
|
||||||
|
그룹 끝 )
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{action.conditions && action.conditions.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{action.conditions.map((condition, condIndex) => (
|
||||||
|
<div key={`action-${actionIndex}-condition-${condition.id}`}>
|
||||||
|
<ActionConditionRenderer
|
||||||
|
condition={condition}
|
||||||
|
condIndex={condIndex}
|
||||||
|
actionIndex={actionIndex}
|
||||||
|
settings={settings}
|
||||||
|
onSettingsChange={onSettingsChange}
|
||||||
|
fromTableColumns={fromTableColumns}
|
||||||
|
getActionCurrentGroupLevel={getActionCurrentGroupLevel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
|
||||||
|
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||||
|
|
||||||
|
interface ActionFieldMappingsProps {
|
||||||
|
action: DataSaveSettings["actions"][0];
|
||||||
|
actionIndex: number;
|
||||||
|
settings: DataSaveSettings;
|
||||||
|
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||||
|
availableTables: TableInfo[];
|
||||||
|
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
||||||
|
action,
|
||||||
|
actionIndex,
|
||||||
|
settings,
|
||||||
|
onSettingsChange,
|
||||||
|
availableTables,
|
||||||
|
tableColumnsCache,
|
||||||
|
}) => {
|
||||||
|
const addFieldMapping = () => {
|
||||||
|
const newActions = [...settings.actions];
|
||||||
|
newActions[actionIndex].fieldMappings.push({
|
||||||
|
sourceTable: "",
|
||||||
|
sourceField: "",
|
||||||
|
targetTable: "",
|
||||||
|
targetField: "",
|
||||||
|
defaultValue: "",
|
||||||
|
transformFunction: "",
|
||||||
|
});
|
||||||
|
onSettingsChange({ ...settings, actions: newActions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFieldMapping = (mappingIndex: number, field: string, value: string) => {
|
||||||
|
const newActions = [...settings.actions];
|
||||||
|
(newActions[actionIndex].fieldMappings[mappingIndex] as any)[field] = value;
|
||||||
|
onSettingsChange({ ...settings, actions: newActions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFieldMapping = (mappingIndex: number) => {
|
||||||
|
const newActions = [...settings.actions];
|
||||||
|
newActions[actionIndex].fieldMappings = newActions[actionIndex].fieldMappings.filter((_, i) => i !== mappingIndex);
|
||||||
|
onSettingsChange({ ...settings, actions: newActions });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-medium">필드 매핑</Label>
|
||||||
|
<Button size="sm" variant="outline" onClick={addFieldMapping} className="h-6 text-xs">
|
||||||
|
<Plus className="mr-1 h-2 w-2" />
|
||||||
|
매핑 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{action.fieldMappings.map((mapping, mappingIndex) => (
|
||||||
|
<div
|
||||||
|
key={`${action.id}-mapping-${mappingIndex}-${mapping.sourceField || "empty"}-${mapping.targetField || "empty"}`}
|
||||||
|
className="rounded border bg-white p-2"
|
||||||
|
>
|
||||||
|
{/* 컴팩트한 매핑 표시 */}
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
{/* 소스 */}
|
||||||
|
<div className="flex items-center gap-1 rounded bg-blue-50 px-2 py-1">
|
||||||
|
<Select
|
||||||
|
value={mapping.sourceTable || "__EMPTY__"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const actualValue = value === "__EMPTY__" ? "" : value;
|
||||||
|
updateFieldMapping(mappingIndex, "sourceTable", actualValue);
|
||||||
|
updateFieldMapping(mappingIndex, "sourceField", "");
|
||||||
|
if (actualValue) {
|
||||||
|
updateFieldMapping(mappingIndex, "defaultValue", "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!!(mapping.defaultValue && mapping.defaultValue.trim())}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-24 border-0 bg-transparent p-0 text-xs">
|
||||||
|
<SelectValue placeholder="테이블" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__EMPTY__">비워두기 (기본값 사용)</SelectItem>
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<SelectItem key={table.tableName} value={table.tableName}>
|
||||||
|
<div className="truncate" title={table.tableName}>
|
||||||
|
{table.tableName}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{mapping.sourceTable && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
updateFieldMapping(mappingIndex, "sourceTable", "");
|
||||||
|
updateFieldMapping(mappingIndex, "sourceField", "");
|
||||||
|
}}
|
||||||
|
className="ml-1 flex h-4 w-4 items-center justify-center rounded-full text-gray-400 hover:bg-gray-200 hover:text-gray-600"
|
||||||
|
title="소스 테이블 지우기"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-400">.</span>
|
||||||
|
<Select
|
||||||
|
value={mapping.sourceField}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateFieldMapping(mappingIndex, "sourceField", value);
|
||||||
|
if (value) {
|
||||||
|
updateFieldMapping(mappingIndex, "defaultValue", "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!mapping.sourceTable || !!(mapping.defaultValue && mapping.defaultValue.trim())}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-24 border-0 bg-transparent p-0 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{mapping.sourceTable &&
|
||||||
|
tableColumnsCache[mapping.sourceTable]?.map((column) => (
|
||||||
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
|
<div className="truncate" title={column.columnName}>
|
||||||
|
{column.columnName}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-gray-400">→</div>
|
||||||
|
|
||||||
|
{/* 타겟 */}
|
||||||
|
<div className="flex items-center gap-1 rounded bg-green-50 px-2 py-1">
|
||||||
|
<Select
|
||||||
|
value={mapping.targetTable || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateFieldMapping(mappingIndex, "targetTable", value);
|
||||||
|
updateFieldMapping(mappingIndex, "targetField", "");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-24 border-0 bg-transparent p-0 text-xs">
|
||||||
|
<SelectValue placeholder="테이블" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<SelectItem key={table.tableName} value={table.tableName}>
|
||||||
|
<div className="truncate" title={table.tableName}>
|
||||||
|
{table.tableName}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-gray-400">.</span>
|
||||||
|
<Select
|
||||||
|
value={mapping.targetField}
|
||||||
|
onValueChange={(value) => updateFieldMapping(mappingIndex, "targetField", value)}
|
||||||
|
disabled={!mapping.targetTable}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-24 border-0 bg-transparent p-0 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{mapping.targetTable &&
|
||||||
|
tableColumnsCache[mapping.targetTable]?.map((column) => (
|
||||||
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
|
<div className="truncate" title={column.columnName}>
|
||||||
|
{column.columnName}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기본값 (인라인) */}
|
||||||
|
<Input
|
||||||
|
value={mapping.defaultValue || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateFieldMapping(mappingIndex, "defaultValue", e.target.value);
|
||||||
|
if (e.target.value.trim()) {
|
||||||
|
updateFieldMapping(mappingIndex, "sourceTable", "");
|
||||||
|
updateFieldMapping(mappingIndex, "sourceField", "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!!mapping.sourceTable}
|
||||||
|
className="h-6 w-20 text-xs"
|
||||||
|
placeholder="기본값"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => removeFieldMapping(mappingIndex)}
|
||||||
|
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { ColumnInfo } from "@/lib/api/dataflow";
|
||||||
|
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||||
|
|
||||||
|
interface ActionSplitConfigProps {
|
||||||
|
action: DataSaveSettings["actions"][0];
|
||||||
|
actionIndex: number;
|
||||||
|
settings: DataSaveSettings;
|
||||||
|
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||||
|
fromTableColumns: ColumnInfo[];
|
||||||
|
toTableColumns: ColumnInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActionSplitConfig: React.FC<ActionSplitConfigProps> = ({
|
||||||
|
action,
|
||||||
|
actionIndex,
|
||||||
|
settings,
|
||||||
|
onSettingsChange,
|
||||||
|
fromTableColumns,
|
||||||
|
toTableColumns,
|
||||||
|
}) => {
|
||||||
|
const updateSplitConfig = (field: string, value: string) => {
|
||||||
|
const newActions = [...settings.actions];
|
||||||
|
if (!newActions[actionIndex].splitConfig) {
|
||||||
|
newActions[actionIndex].splitConfig = {
|
||||||
|
sourceField: "",
|
||||||
|
delimiter: ",",
|
||||||
|
targetField: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
(newActions[actionIndex].splitConfig as any)[field] = value;
|
||||||
|
onSettingsChange({ ...settings, actions: newActions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSplitConfig = () => {
|
||||||
|
const newActions = [...settings.actions];
|
||||||
|
newActions[actionIndex].splitConfig = {
|
||||||
|
sourceField: "",
|
||||||
|
delimiter: ",",
|
||||||
|
targetField: "",
|
||||||
|
};
|
||||||
|
onSettingsChange({ ...settings, actions: newActions });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
<details className="group">
|
||||||
|
<summary className="flex cursor-pointer items-center justify-between rounded border p-2 text-xs font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
✂️ 데이터 분할 설정 (선택사항)
|
||||||
|
{action.splitConfig && action.splitConfig.sourceField && (
|
||||||
|
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">설정됨</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action.splitConfig && action.splitConfig.sourceField && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
clearSplitConfig();
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||||
|
title="분할 설정 초기화"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-2 border-l-2 border-gray-100 pl-4">
|
||||||
|
<Label className="text-xs font-medium">데이터 분할 설정</Label>
|
||||||
|
<div className="mt-1 grid grid-cols-3 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-500">분할할 필드</Label>
|
||||||
|
<Select
|
||||||
|
value={action.splitConfig?.sourceField || ""}
|
||||||
|
onValueChange={(value) => updateSplitConfig("sourceField", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 text-xs">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fromTableColumns.map((column) => (
|
||||||
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
|
{column.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-500">구분자</Label>
|
||||||
|
<Input
|
||||||
|
value={action.splitConfig?.delimiter || ""}
|
||||||
|
onChange={(e) => updateSplitConfig("delimiter", e.target.value)}
|
||||||
|
className="h-6 text-xs"
|
||||||
|
placeholder=","
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-500">저장할 필드</Label>
|
||||||
|
<Select
|
||||||
|
value={action.splitConfig?.targetField || ""}
|
||||||
|
onValueChange={(value) => updateSplitConfig("targetField", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 text-xs">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{toTableColumns.map((column) => (
|
||||||
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
|
{column.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Key, Save, Globe } from "lucide-react";
|
||||||
|
import { ConnectionConfig } from "@/types/connectionTypes";
|
||||||
|
|
||||||
|
interface ConnectionTypeSelectorProps {
|
||||||
|
config: ConnectionConfig;
|
||||||
|
onConfigChange: (config: ConnectionConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({ config, onConfigChange }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">연결 종류</Label>
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||||
|
<div
|
||||||
|
className={`cursor-pointer rounded-lg border-2 p-3 text-center transition-colors ${
|
||||||
|
config.connectionType === "simple-key"
|
||||||
|
? "border-blue-500 bg-blue-50"
|
||||||
|
: "border-gray-200 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
onClick={() => onConfigChange({ ...config, connectionType: "simple-key" })}
|
||||||
|
>
|
||||||
|
<Key className="mx-auto h-6 w-6 text-blue-500" />
|
||||||
|
<div className="mt-1 text-xs font-medium">단순 키값 연결</div>
|
||||||
|
<div className="text-xs text-gray-600">중계 테이블 생성</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`cursor-pointer rounded-lg border-2 p-3 text-center transition-colors ${
|
||||||
|
config.connectionType === "data-save"
|
||||||
|
? "border-green-500 bg-green-50"
|
||||||
|
: "border-gray-200 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
onClick={() => onConfigChange({ ...config, connectionType: "data-save" })}
|
||||||
|
>
|
||||||
|
<Save className="mx-auto h-6 w-6 text-green-500" />
|
||||||
|
<div className="mt-1 text-xs font-medium">데이터 저장</div>
|
||||||
|
<div className="text-xs text-gray-600">필드 매핑 저장</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`cursor-pointer rounded-lg border-2 p-3 text-center transition-colors ${
|
||||||
|
config.connectionType === "external-call"
|
||||||
|
? "border-orange-500 bg-orange-50"
|
||||||
|
: "border-gray-200 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
onClick={() => onConfigChange({ ...config, connectionType: "external-call" })}
|
||||||
|
>
|
||||||
|
<Globe className="mx-auto h-6 w-6 text-orange-500" />
|
||||||
|
<div className="mt-1 text-xs font-medium">외부 호출</div>
|
||||||
|
<div className="text-xs text-gray-600">API/이메일 호출</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Plus, Save, Trash2 } from "lucide-react";
|
||||||
|
import { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
|
||||||
|
import { DataSaveSettings as DataSaveSettingsType } from "@/types/connectionTypes";
|
||||||
|
import { ActionConditionsSection } from "./ActionConditionsSection";
|
||||||
|
import { ActionFieldMappings } from "./ActionFieldMappings";
|
||||||
|
import { ActionSplitConfig } from "./ActionSplitConfig";
|
||||||
|
|
||||||
|
interface DataSaveSettingsProps {
|
||||||
|
settings: DataSaveSettingsType;
|
||||||
|
onSettingsChange: (settings: DataSaveSettingsType) => void;
|
||||||
|
availableTables: TableInfo[];
|
||||||
|
fromTableColumns: ColumnInfo[];
|
||||||
|
toTableColumns: ColumnInfo[];
|
||||||
|
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
||||||
|
settings,
|
||||||
|
onSettingsChange,
|
||||||
|
availableTables,
|
||||||
|
fromTableColumns,
|
||||||
|
toTableColumns,
|
||||||
|
tableColumnsCache,
|
||||||
|
}) => {
|
||||||
|
const addAction = () => {
|
||||||
|
const newAction = {
|
||||||
|
id: `action_${settings.actions.length + 1}`,
|
||||||
|
name: `액션 ${settings.actions.length + 1}`,
|
||||||
|
actionType: "insert" as const,
|
||||||
|
fieldMappings: [],
|
||||||
|
conditions: [],
|
||||||
|
splitConfig: {
|
||||||
|
sourceField: "",
|
||||||
|
delimiter: "",
|
||||||
|
targetField: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
onSettingsChange({
|
||||||
|
...settings,
|
||||||
|
actions: [...settings.actions, newAction],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAction = (actionIndex: number, field: string, value: any) => {
|
||||||
|
const newActions = [...settings.actions];
|
||||||
|
(newActions[actionIndex] as any)[field] = value;
|
||||||
|
onSettingsChange({ ...settings, actions: newActions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAction = (actionIndex: number) => {
|
||||||
|
const newActions = settings.actions.filter((_, i) => i !== actionIndex);
|
||||||
|
onSettingsChange({ ...settings, actions: newActions });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-l-4 border-l-green-500 bg-green-50/30 p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Save className="h-4 w-4 text-green-500" />
|
||||||
|
<span className="text-sm font-medium">데이터 저장 설정</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 액션 목록 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-medium">저장 액션</Label>
|
||||||
|
<Button size="sm" variant="outline" onClick={addAction} className="h-7 text-xs">
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
액션 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.actions.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed p-3 text-center text-xs text-gray-500">
|
||||||
|
저장 액션을 추가하여 데이터를 어떻게 저장할지 설정하세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{settings.actions.map((action, actionIndex) => (
|
||||||
|
<div key={action.id} className="rounded border bg-white p-3">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<Input
|
||||||
|
value={action.name}
|
||||||
|
onChange={(e) => updateAction(actionIndex, "name", e.target.value)}
|
||||||
|
className="h-7 flex-1 text-xs font-medium"
|
||||||
|
placeholder="액션 이름"
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => removeAction(actionIndex)} className="h-7 w-7 p-0">
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{/* 액션 타입 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">액션 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={action.actionType}
|
||||||
|
onValueChange={(value: "insert" | "update" | "delete" | "upsert") =>
|
||||||
|
updateAction(actionIndex, "actionType", value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="insert">INSERT</SelectItem>
|
||||||
|
<SelectItem value="update">UPDATE</SelectItem>
|
||||||
|
<SelectItem value="delete">DELETE</SelectItem>
|
||||||
|
<SelectItem value="upsert">UPSERT</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션별 개별 실행 조건 */}
|
||||||
|
<ActionConditionsSection
|
||||||
|
action={action}
|
||||||
|
actionIndex={actionIndex}
|
||||||
|
settings={settings}
|
||||||
|
onSettingsChange={onSettingsChange}
|
||||||
|
fromTableColumns={fromTableColumns}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 데이터 분할 설정 */}
|
||||||
|
<ActionSplitConfig
|
||||||
|
action={action}
|
||||||
|
actionIndex={actionIndex}
|
||||||
|
settings={settings}
|
||||||
|
onSettingsChange={onSettingsChange}
|
||||||
|
fromTableColumns={fromTableColumns}
|
||||||
|
toTableColumns={toTableColumns}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 필드 매핑 */}
|
||||||
|
<ActionFieldMappings
|
||||||
|
action={action}
|
||||||
|
actionIndex={actionIndex}
|
||||||
|
settings={settings}
|
||||||
|
onSettingsChange={onSettingsChange}
|
||||||
|
availableTables={availableTables}
|
||||||
|
tableColumnsCache={tableColumnsCache}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Globe } from "lucide-react";
|
||||||
|
import { ExternalCallSettings as ExternalCallSettingsType } from "@/types/connectionTypes";
|
||||||
|
|
||||||
|
interface ExternalCallSettingsProps {
|
||||||
|
settings: ExternalCallSettingsType;
|
||||||
|
onSettingsChange: (settings: ExternalCallSettingsType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ settings, onSettingsChange }) => {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-l-4 border-l-orange-500 bg-orange-50/30 p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Globe className="h-4 w-4 text-orange-500" />
|
||||||
|
<span className="text-sm font-medium">외부 호출 설정</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="callType" className="text-sm">
|
||||||
|
호출 유형
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={settings.callType}
|
||||||
|
onValueChange={(value: "rest-api" | "email" | "webhook" | "ftp" | "queue") =>
|
||||||
|
onSettingsChange({ ...settings, callType: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="rest-api">REST API 호출</SelectItem>
|
||||||
|
<SelectItem value="email">이메일 전송</SelectItem>
|
||||||
|
<SelectItem value="webhook">웹훅</SelectItem>
|
||||||
|
<SelectItem value="ftp">FTP 업로드</SelectItem>
|
||||||
|
<SelectItem value="queue">메시지 큐</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.callType === "rest-api" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="apiUrl" className="text-sm">
|
||||||
|
API URL
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="apiUrl"
|
||||||
|
value={settings.apiUrl}
|
||||||
|
onChange={(e) => onSettingsChange({ ...settings, apiUrl: e.target.value })}
|
||||||
|
placeholder="https://api.example.com/webhook"
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="httpMethod" className="text-sm">
|
||||||
|
HTTP Method
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={settings.httpMethod}
|
||||||
|
onValueChange={(value: "GET" | "POST" | "PUT" | "DELETE") =>
|
||||||
|
onSettingsChange({ ...settings, httpMethod: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="GET">GET</SelectItem>
|
||||||
|
<SelectItem value="POST">POST</SelectItem>
|
||||||
|
<SelectItem value="PUT">PUT</SelectItem>
|
||||||
|
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="headers" className="text-sm">
|
||||||
|
Headers
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="headers"
|
||||||
|
value={settings.headers}
|
||||||
|
onChange={(e) => onSettingsChange({ ...settings, headers: e.target.value })}
|
||||||
|
placeholder="{}"
|
||||||
|
rows={1}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="bodyTemplate" className="text-sm">
|
||||||
|
Body Template
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="bodyTemplate"
|
||||||
|
value={settings.bodyTemplate}
|
||||||
|
onChange={(e) => onSettingsChange({ ...settings, bodyTemplate: e.target.value })}
|
||||||
|
placeholder="{}"
|
||||||
|
rows={2}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Key } from "lucide-react";
|
||||||
|
import { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
|
||||||
|
import { SimpleKeySettings as SimpleKeySettingsType } from "@/types/connectionTypes";
|
||||||
|
|
||||||
|
interface SimpleKeySettingsProps {
|
||||||
|
settings: SimpleKeySettingsType;
|
||||||
|
onSettingsChange: (settings: SimpleKeySettingsType) => void;
|
||||||
|
availableTables: TableInfo[];
|
||||||
|
selectedFromTable: string;
|
||||||
|
selectedToTable: string;
|
||||||
|
fromTableColumns: ColumnInfo[];
|
||||||
|
toTableColumns: ColumnInfo[];
|
||||||
|
selectedFromColumns: string[];
|
||||||
|
selectedToColumns: string[];
|
||||||
|
onFromColumnsChange: (columns: string[]) => void;
|
||||||
|
onToColumnsChange: (columns: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
|
||||||
|
settings,
|
||||||
|
onSettingsChange,
|
||||||
|
availableTables,
|
||||||
|
selectedFromTable,
|
||||||
|
selectedToTable,
|
||||||
|
fromTableColumns,
|
||||||
|
toTableColumns,
|
||||||
|
selectedFromColumns,
|
||||||
|
selectedToColumns,
|
||||||
|
onFromColumnsChange,
|
||||||
|
onToColumnsChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 테이블 및 컬럼 선택 */}
|
||||||
|
<div className="rounded-lg border bg-gray-50 p-4">
|
||||||
|
<div className="mb-4 text-sm font-medium">테이블 및 컬럼 선택</div>
|
||||||
|
|
||||||
|
{/* 현재 선택된 테이블 표시 */}
|
||||||
|
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium text-gray-600">From 테이블</Label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<span className="text-sm font-medium text-gray-800">
|
||||||
|
{availableTables.find((t) => t.tableName === selectedFromTable)?.displayName || selectedFromTable}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-xs text-gray-500">({selectedFromTable})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium text-gray-600">To 테이블</Label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<span className="text-sm font-medium text-gray-800">
|
||||||
|
{availableTables.find((t) => t.tableName === selectedToTable)?.displayName || selectedToTable}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-xs text-gray-500">({selectedToTable})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 선택 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium text-gray-600">From 컬럼</Label>
|
||||||
|
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
|
||||||
|
{fromTableColumns.map((column) => (
|
||||||
|
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedFromColumns.includes(column.columnName)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
onFromColumnsChange([...selectedFromColumns, column.columnName]);
|
||||||
|
} else {
|
||||||
|
onFromColumnsChange(selectedFromColumns.filter((col) => col !== column.columnName));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span>{column.columnName}</span>
|
||||||
|
<span className="text-xs text-gray-500">({column.dataType})</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{fromTableColumns.length === 0 && (
|
||||||
|
<div className="py-2 text-xs text-gray-500">
|
||||||
|
{selectedFromTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium text-gray-600">To 컬럼</Label>
|
||||||
|
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
|
||||||
|
{toTableColumns.map((column) => (
|
||||||
|
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedToColumns.includes(column.columnName)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
onToColumnsChange([...selectedToColumns, column.columnName]);
|
||||||
|
} else {
|
||||||
|
onToColumnsChange(selectedToColumns.filter((col) => col !== column.columnName));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span>{column.columnName}</span>
|
||||||
|
<span className="text-xs text-gray-500">({column.dataType})</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{toTableColumns.length === 0 && (
|
||||||
|
<div className="py-2 text-xs text-gray-500">
|
||||||
|
{selectedToTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택된 컬럼 미리보기 */}
|
||||||
|
{(selectedFromColumns.length > 0 || selectedToColumns.length > 0) && (
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium text-gray-600">선택된 From 컬럼</Label>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{selectedFromColumns.length > 0 ? (
|
||||||
|
selectedFromColumns.map((column) => (
|
||||||
|
<Badge key={column} variant="outline" className="text-xs">
|
||||||
|
{column}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">선택된 컬럼 없음</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium text-gray-600">선택된 To 컬럼</Label>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{selectedToColumns.length > 0 ? (
|
||||||
|
selectedToColumns.map((column) => (
|
||||||
|
<Badge key={column} variant="secondary" className="text-xs">
|
||||||
|
{column}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">선택된 컬럼 없음</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 단순 키값 연결 설정 */}
|
||||||
|
<div className="rounded-lg border border-l-4 border-l-blue-500 bg-blue-50/30 p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Key className="h-4 w-4 text-blue-500" />
|
||||||
|
<span className="text-sm font-medium">단순 키값 연결 설정</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="notes" className="text-sm">
|
||||||
|
연결 설명
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
value={settings.notes}
|
||||||
|
onChange={(e) => onSettingsChange({ ...settings, notes: e.target.value })}
|
||||||
|
placeholder="데이터 연결에 대한 설명을 입력하세요"
|
||||||
|
rows={2}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { ConditionNode } from "@/lib/api/dataflow";
|
||||||
|
import {
|
||||||
|
generateConditionId,
|
||||||
|
generateGroupId,
|
||||||
|
generateActionGroupId,
|
||||||
|
findOpenGroups,
|
||||||
|
getNextGroupLevel,
|
||||||
|
getCurrentGroupLevel,
|
||||||
|
} from "@/utils/connectionUtils";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
export const useConditionManager = () => {
|
||||||
|
const [conditions, setConditions] = useState<ConditionNode[]>([]);
|
||||||
|
|
||||||
|
// 조건 추가
|
||||||
|
const addCondition = useCallback(() => {
|
||||||
|
const newCondition: ConditionNode = {
|
||||||
|
id: generateConditionId(),
|
||||||
|
type: "condition" as const,
|
||||||
|
field: "",
|
||||||
|
operator: "=",
|
||||||
|
value: "",
|
||||||
|
dataType: "string",
|
||||||
|
// 첫 번째 조건이 아니고, 바로 앞이 group-start가 아니면 logicalOperator 추가
|
||||||
|
...(conditions.length > 0 &&
|
||||||
|
conditions[conditions.length - 1]?.type !== "group-start" && { logicalOperator: "AND" as const }),
|
||||||
|
};
|
||||||
|
|
||||||
|
setConditions([...conditions, newCondition]);
|
||||||
|
}, [conditions]);
|
||||||
|
|
||||||
|
// 그룹 시작 추가
|
||||||
|
const addGroupStart = useCallback(() => {
|
||||||
|
const groupId = generateGroupId();
|
||||||
|
const groupLevel = getNextGroupLevel(conditions);
|
||||||
|
|
||||||
|
const groupStart: ConditionNode = {
|
||||||
|
id: generateConditionId(),
|
||||||
|
type: "group-start" as const,
|
||||||
|
groupId,
|
||||||
|
groupLevel,
|
||||||
|
// 첫 번째 그룹이 아니면 logicalOperator 추가
|
||||||
|
...(conditions.length > 0 && { logicalOperator: "AND" as const }),
|
||||||
|
};
|
||||||
|
|
||||||
|
setConditions([...conditions, groupStart]);
|
||||||
|
}, [conditions]);
|
||||||
|
|
||||||
|
// 그룹 끝 추가
|
||||||
|
const addGroupEnd = useCallback(() => {
|
||||||
|
// 가장 최근에 열린 그룹 찾기
|
||||||
|
const openGroups = findOpenGroups(conditions);
|
||||||
|
if (openGroups.length === 0) {
|
||||||
|
toast.error("닫을 그룹이 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastOpenGroup = openGroups[openGroups.length - 1];
|
||||||
|
const groupEnd: ConditionNode = {
|
||||||
|
id: generateConditionId(),
|
||||||
|
type: "group-end" as const,
|
||||||
|
groupId: lastOpenGroup.groupId,
|
||||||
|
groupLevel: lastOpenGroup.groupLevel,
|
||||||
|
};
|
||||||
|
|
||||||
|
setConditions([...conditions, groupEnd]);
|
||||||
|
}, [conditions]);
|
||||||
|
|
||||||
|
// 조건 업데이트
|
||||||
|
const updateCondition = useCallback(
|
||||||
|
(index: number, field: keyof ConditionNode, value: string) => {
|
||||||
|
const updatedConditions = [...conditions];
|
||||||
|
updatedConditions[index] = { ...updatedConditions[index], [field]: value };
|
||||||
|
setConditions(updatedConditions);
|
||||||
|
},
|
||||||
|
[conditions],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 조건 제거
|
||||||
|
const removeCondition = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const conditionToRemove = conditions[index];
|
||||||
|
|
||||||
|
// 그룹 시작/끝을 삭제하는 경우 해당 그룹 전체 삭제
|
||||||
|
if (conditionToRemove.type === "group-start" || conditionToRemove.type === "group-end") {
|
||||||
|
const updatedConditions = conditions.filter((c) => c.groupId !== conditionToRemove.groupId);
|
||||||
|
setConditions(updatedConditions);
|
||||||
|
} else {
|
||||||
|
const updatedConditions = conditions.filter((_, i) => i !== index);
|
||||||
|
setConditions(updatedConditions);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[conditions],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 그룹 전체 삭제
|
||||||
|
const removeGroup = useCallback(
|
||||||
|
(groupId: string) => {
|
||||||
|
const updatedConditions = conditions.filter((c) => c.groupId !== groupId);
|
||||||
|
setConditions(updatedConditions);
|
||||||
|
},
|
||||||
|
[conditions],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
conditions,
|
||||||
|
setConditions,
|
||||||
|
addCondition,
|
||||||
|
addGroupStart,
|
||||||
|
addGroupEnd,
|
||||||
|
updateCondition,
|
||||||
|
removeCondition,
|
||||||
|
removeGroup,
|
||||||
|
getCurrentGroupLevel: (conditionIndex: number) => getCurrentGroupLevel(conditions, conditionIndex),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 액션별 조건 관리를 위한 헬퍼 함수들
|
||||||
|
export const useActionConditionHelpers = () => {
|
||||||
|
// 액션별 그룹 시작 추가
|
||||||
|
const addActionGroupStart = useCallback(
|
||||||
|
(actionIndex: number, dataSaveSettings: any, setDataSaveSettings: (settings: any) => void) => {
|
||||||
|
const groupId = generateActionGroupId();
|
||||||
|
const currentConditions = dataSaveSettings.actions[actionIndex].conditions || [];
|
||||||
|
const groupLevel = getNextGroupLevel(currentConditions);
|
||||||
|
|
||||||
|
const groupStart: ConditionNode = {
|
||||||
|
id: generateConditionId(),
|
||||||
|
type: "group-start" as const,
|
||||||
|
groupId,
|
||||||
|
groupLevel,
|
||||||
|
// 첫 번째 그룹이 아니면 logicalOperator 추가
|
||||||
|
...(currentConditions.length > 0 && { logicalOperator: "AND" as const }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const newActions = [...dataSaveSettings.actions];
|
||||||
|
newActions[actionIndex].conditions = [...currentConditions, groupStart];
|
||||||
|
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 액션별 그룹 끝 추가
|
||||||
|
const addActionGroupEnd = useCallback(
|
||||||
|
(actionIndex: number, dataSaveSettings: any, setDataSaveSettings: (settings: any) => void) => {
|
||||||
|
const currentConditions = dataSaveSettings.actions[actionIndex].conditions || [];
|
||||||
|
const openGroups = findOpenGroups(currentConditions);
|
||||||
|
|
||||||
|
if (openGroups.length === 0) {
|
||||||
|
toast.error("닫을 그룹이 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastOpenGroup = openGroups[openGroups.length - 1];
|
||||||
|
const groupEnd: ConditionNode = {
|
||||||
|
id: generateConditionId(),
|
||||||
|
type: "group-end" as const,
|
||||||
|
groupId: lastOpenGroup.groupId,
|
||||||
|
groupLevel: lastOpenGroup.groupLevel,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newActions = [...dataSaveSettings.actions];
|
||||||
|
newActions[actionIndex].conditions = [...currentConditions, groupEnd];
|
||||||
|
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 액션별 현재 조건의 그룹 레벨 계산
|
||||||
|
const getActionCurrentGroupLevel = useCallback((conditions: ConditionNode[], conditionIndex: number): number => {
|
||||||
|
return getCurrentGroupLevel(conditions, conditionIndex);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
addActionGroupStart,
|
||||||
|
addActionGroupEnd,
|
||||||
|
getActionCurrentGroupLevel,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { ConditionNode } from "@/lib/api/dataflow";
|
||||||
|
|
||||||
|
// 연결 정보 타입
|
||||||
|
export interface ConnectionInfo {
|
||||||
|
fromNode: {
|
||||||
|
id: string;
|
||||||
|
tableName: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
toNode: {
|
||||||
|
id: string;
|
||||||
|
tableName: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
fromColumn?: string;
|
||||||
|
toColumn?: string;
|
||||||
|
selectedColumnsData?: {
|
||||||
|
[tableName: string]: {
|
||||||
|
displayName: string;
|
||||||
|
columns: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
existingRelationship?: {
|
||||||
|
relationshipName: string;
|
||||||
|
connectionType: string;
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연결 설정 타입
|
||||||
|
export interface ConnectionConfig {
|
||||||
|
relationshipName: string;
|
||||||
|
connectionType: "simple-key" | "data-save" | "external-call";
|
||||||
|
fromColumnName: string;
|
||||||
|
toColumnName: string;
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 단순 키값 연결 설정
|
||||||
|
export interface SimpleKeySettings {
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 저장 설정
|
||||||
|
export interface DataSaveSettings {
|
||||||
|
actions: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
actionType: "insert" | "update" | "delete" | "upsert";
|
||||||
|
conditions?: ConditionNode[];
|
||||||
|
fieldMappings: Array<{
|
||||||
|
sourceTable?: string;
|
||||||
|
sourceField: string;
|
||||||
|
targetTable?: string;
|
||||||
|
targetField: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
transformFunction?: string;
|
||||||
|
}>;
|
||||||
|
splitConfig?: {
|
||||||
|
sourceField: string; // 분할할 소스 필드
|
||||||
|
delimiter: string; // 구분자 (예: ",")
|
||||||
|
targetField: string; // 분할된 값이 들어갈 필드
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 외부 호출 설정
|
||||||
|
export interface ExternalCallSettings {
|
||||||
|
callType: "rest-api" | "email" | "webhook" | "ftp" | "queue";
|
||||||
|
apiUrl?: string;
|
||||||
|
httpMethod?: "GET" | "POST" | "PUT" | "DELETE";
|
||||||
|
headers?: string;
|
||||||
|
bodyTemplate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionSetupModal Props 타입
|
||||||
|
export interface ConnectionSetupModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
connection: ConnectionInfo | null;
|
||||||
|
companyCode: string;
|
||||||
|
onConfirm: (relationship: any) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { ConditionNode } from "@/lib/api/dataflow";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고유 ID 생성 함수
|
||||||
|
*/
|
||||||
|
export const generateConditionId = (): string => {
|
||||||
|
return `cond_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 ID 생성 함수
|
||||||
|
*/
|
||||||
|
export const generateGroupId = (): string => {
|
||||||
|
return `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 액션 그룹 ID 생성 함수
|
||||||
|
*/
|
||||||
|
export const generateActionGroupId = (): string => {
|
||||||
|
return `action_group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 열린 그룹 찾기
|
||||||
|
*/
|
||||||
|
export const findOpenGroups = (conditions: ConditionNode[]) => {
|
||||||
|
const openGroups: Array<{ groupId: string; groupLevel: number }> = [];
|
||||||
|
|
||||||
|
for (const condition of conditions) {
|
||||||
|
if (condition.type === "group-start") {
|
||||||
|
openGroups.push({
|
||||||
|
groupId: condition.groupId!,
|
||||||
|
groupLevel: condition.groupLevel!,
|
||||||
|
});
|
||||||
|
} else if (condition.type === "group-end") {
|
||||||
|
// 해당 그룹 제거
|
||||||
|
const groupIndex = openGroups.findIndex((g) => g.groupId === condition.groupId);
|
||||||
|
if (groupIndex !== -1) {
|
||||||
|
openGroups.splice(groupIndex, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return openGroups;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다음 그룹 레벨 계산
|
||||||
|
*/
|
||||||
|
export const getNextGroupLevel = (conditions: ConditionNode[]): number => {
|
||||||
|
const openGroups = findOpenGroups(conditions);
|
||||||
|
return openGroups.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 조건의 그룹 레벨 계산
|
||||||
|
*/
|
||||||
|
export const getCurrentGroupLevel = (conditions: ConditionNode[], conditionIndex: number): number => {
|
||||||
|
let level = 0;
|
||||||
|
for (let i = 0; i < conditionIndex; i++) {
|
||||||
|
const condition = conditions[i];
|
||||||
|
if (condition.type === "group-start") {
|
||||||
|
level++;
|
||||||
|
} else if (condition.type === "group-end") {
|
||||||
|
level--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return level;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연결인지 확인하는 헬퍼 함수
|
||||||
|
*/
|
||||||
|
export const isConditionalConnection = (connectionType: string): boolean => {
|
||||||
|
return connectionType === "data-save" || connectionType === "external-call";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 타입에 따른 입력 타입 결정
|
||||||
|
*/
|
||||||
|
export const getInputTypeForDataType = (dataType: string): "text" | "number" | "datetime-local" | "date" | "time" => {
|
||||||
|
const lowerDataType = dataType?.toLowerCase() || "string";
|
||||||
|
|
||||||
|
if (lowerDataType.includes("timestamp") || lowerDataType.includes("datetime")) {
|
||||||
|
return "datetime-local";
|
||||||
|
} else if (lowerDataType.includes("date")) {
|
||||||
|
return "date";
|
||||||
|
} else if (lowerDataType.includes("time")) {
|
||||||
|
return "time";
|
||||||
|
} else if (
|
||||||
|
lowerDataType.includes("int") ||
|
||||||
|
lowerDataType.includes("numeric") ||
|
||||||
|
lowerDataType.includes("decimal") ||
|
||||||
|
lowerDataType.includes("float") ||
|
||||||
|
lowerDataType.includes("double")
|
||||||
|
) {
|
||||||
|
return "number";
|
||||||
|
} else {
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue