feat: Add procedure and function management in flow controller

- Introduced new endpoints in FlowController for listing procedures and retrieving procedure parameters, enhancing the flow management capabilities.
- Updated FlowDataMoveService to support procedure calls during data movement, ensuring seamless integration with external and internal databases.
- Enhanced NodeFlowExecutionService to execute procedure call actions, allowing for dynamic execution of stored procedures within flow nodes.
- Updated frontend components to support procedure selection and parameter management, improving user experience in configuring flow steps.
- Added necessary types and API functions for handling procedure-related data, ensuring type safety and clarity in implementation.
This commit is contained in:
kjs
2026-03-03 14:33:17 +09:00
parent fd5c61b12a
commit f697e1e897
21 changed files with 2303 additions and 41 deletions

View File

@@ -32,6 +32,7 @@ import { LogNode } from "./nodes/LogNode";
import { EmailActionNode } from "./nodes/EmailActionNode";
import { ScriptActionNode } from "./nodes/ScriptActionNode";
import { HttpRequestActionNode } from "./nodes/HttpRequestActionNode";
import { ProcedureCallActionNode } from "./nodes/ProcedureCallActionNode";
import { validateFlow } from "@/lib/utils/flowValidation";
import type { FlowValidation } from "@/lib/utils/flowValidation";
@@ -55,6 +56,7 @@ const nodeTypes = {
emailAction: EmailActionNode,
scriptAction: ScriptActionNode,
httpRequestAction: HttpRequestActionNode,
procedureCallAction: ProcedureCallActionNode,
// 유틸리티
comment: CommentNode,
log: LogNode,

View File

@@ -0,0 +1,121 @@
"use client";
/**
* 프로시저/함수 호출 액션 노드
* 내부 또는 외부 DB의 프로시저/함수를 호출하는 노드
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Database, Workflow } from "lucide-react";
import type { ProcedureCallActionNodeData } from "@/types/node-editor";
export const ProcedureCallActionNode = memo(
({ data, selected }: NodeProps<ProcedureCallActionNodeData>) => {
const hasProcedure = !!data.procedureName;
const inParams = data.parameters?.filter((p) => p.mode === "IN" || p.mode === "INOUT") ?? [];
const outParams = data.parameters?.filter((p) => p.mode === "OUT" || p.mode === "INOUT") ?? [];
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-violet-500 shadow-lg" : "border-gray-200"
}`}
>
{/* 입력 핸들 */}
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !border-2 !border-white !bg-violet-500"
/>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-violet-500 px-3 py-2 text-white">
<Workflow className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">
{data.displayName || "프로시저 호출"}
</div>
</div>
</div>
{/* 본문 */}
<div className="space-y-2 p-3">
{/* DB 소스 */}
<div className="flex items-center gap-2">
<Database className="h-3 w-3 text-gray-400" />
<span className="text-xs text-gray-600">
{data.dbSource === "external" ? (
<span className="rounded bg-amber-100 px-2 py-0.5 text-amber-700">
{data.connectionName || "외부 DB"}
</span>
) : (
<span className="rounded bg-blue-100 px-2 py-0.5 text-blue-700">
DB
</span>
)}
</span>
<span
className={`ml-auto rounded px-2 py-0.5 text-xs font-medium ${
data.callType === "function"
? "bg-cyan-100 text-cyan-700"
: "bg-violet-100 text-violet-700"
}`}
>
{data.callType === "function" ? "FUNCTION" : "PROCEDURE"}
</span>
</div>
{/* 프로시저명 */}
<div className="flex items-center gap-2 text-xs">
<Workflow className="h-3 w-3 text-gray-400" />
{hasProcedure ? (
<span className="font-mono text-green-600 truncate">
{data.procedureSchema && data.procedureSchema !== "public"
? `${data.procedureSchema}.`
: ""}
{data.procedureName}()
</span>
) : (
<span className="text-orange-500"> </span>
)}
</div>
{/* 파라미터 수 */}
{hasProcedure && inParams.length > 0 && (
<div className="text-xs text-gray-500">
: {inParams.length}
</div>
)}
{/* 반환 필드 */}
{hasProcedure && outParams.length > 0 && (
<div className="mt-1 space-y-1 border-t border-gray-100 pt-1">
<div className="text-[10px] font-medium text-green-600">
:
</div>
{outParams.map((p) => (
<div
key={p.name}
className="flex items-center justify-between rounded bg-green-50 px-2 py-0.5 text-[10px]"
>
<span className="font-mono text-green-700">{p.name}</span>
<span className="text-gray-400">{p.dataType}</span>
</div>
))}
</div>
)}
</div>
{/* 출력 핸들 */}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-white !bg-violet-500"
/>
</div>
);
}
);
ProcedureCallActionNode.displayName = "ProcedureCallActionNode";

View File

@@ -23,6 +23,7 @@ import { LogProperties } from "./properties/LogProperties";
import { EmailActionProperties } from "./properties/EmailActionProperties";
import { ScriptActionProperties } from "./properties/ScriptActionProperties";
import { HttpRequestActionProperties } from "./properties/HttpRequestActionProperties";
import { ProcedureCallActionProperties } from "./properties/ProcedureCallActionProperties";
import type { NodeType } from "@/types/node-editor";
export function PropertiesPanel() {
@@ -147,6 +148,9 @@ function NodePropertiesRenderer({ node }: { node: any }) {
case "httpRequestAction":
return <HttpRequestActionProperties nodeId={node.id} data={node.data} />;
case "procedureCallAction":
return <ProcedureCallActionProperties nodeId={node.id} data={node.data} />;
default:
return (
<div className="p-4">
@@ -185,6 +189,7 @@ function getNodeTypeLabel(type: NodeType): string {
emailAction: "메일 발송",
scriptAction: "스크립트 실행",
httpRequestAction: "HTTP 요청",
procedureCallAction: "프로시저 호출",
comment: "주석",
log: "로그",
};

View File

@@ -20,6 +20,7 @@ import { tableTypeApi } from "@/lib/api/screen";
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
import { getNumberingRules } from "@/lib/api/numberingRule";
import type { NumberingRuleConfig } from "@/types/numbering-rule";
import { getFlowProcedureParameters } from "@/lib/api/flow";
import type { InsertActionNodeData } from "@/types/node-editor";
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
@@ -171,10 +172,19 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
useEffect(() => {
// 프로시저 노드 정보를 수집하여 비동기 파라미터 조회에 사용
const procedureNodes: Array<{
procedureName: string;
dbSource: "internal" | "external";
connectionId?: number;
schema?: string;
sourcePath: string[];
}> = [];
const getAllSourceFields = (
targetNodeId: string,
visitedNodes = new Set<string>(),
sourcePath: string[] = [], // 🔥 소스 경로 추적
sourcePath: string[] = [],
): { fields: Array<{ name: string; label?: string; sourcePath?: string[] }>; hasRestAPI: boolean } => {
if (visitedNodes.has(targetNodeId)) {
console.log(`⚠️ 순환 참조 감지: ${targetNodeId} (이미 방문함)`);
@@ -366,7 +376,48 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
}
}
// 5통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
// 5프로시저 호출 노드: 상위 필드 + OUT 파라미터(반환 필드) 추가
else if (node.type === "procedureCallAction") {
console.log("✅ 프로시저 호출 노드 발견");
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
const nodeData = node.data as any;
const procParams = nodeData.parameters;
let hasOutParams = false;
if (Array.isArray(procParams)) {
for (const p of procParams) {
if (p.mode === "OUT" || p.mode === "INOUT") {
hasOutParams = true;
fields.push({
name: p.name,
label: `${p.name} (프로시저 반환)`,
sourcePath: currentPath,
});
}
}
}
// OUT 파라미터가 저장되어 있지 않으면 API로 동적 조회 예약
if (!hasOutParams && nodeData.procedureName) {
procedureNodes.push({
procedureName: nodeData.procedureName,
dbSource: nodeData.dbSource || "internal",
connectionId: nodeData.connectionId,
schema: nodeData.procedureSchema || "public",
sourcePath: currentPath,
});
}
fields.push({
name: "_procedureReturn",
label: "프로시저 반환값",
sourcePath: currentPath,
});
}
// 6⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
else {
console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`);
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
@@ -386,31 +437,66 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
console.log(` - 총 필드 수: ${result.fields.length}`);
console.log(` - REST API 포함: ${result.hasRestAPI}`);
// 🔥 중복 제거 개선: 필드명이 같아도 소스가 다르면 모두 표시
const fieldMap = new Map<string, (typeof result.fields)[number]>();
const duplicateFields = new Set<string>();
const applyFields = (allFields: typeof result.fields) => {
const fieldMap = new Map<string, (typeof result.fields)[number]>();
const duplicateFields = new Set<string>();
result.fields.forEach((field) => {
const key = `${field.name}`;
if (fieldMap.has(key)) {
duplicateFields.add(field.name);
allFields.forEach((field) => {
const key = `${field.name}`;
if (fieldMap.has(key)) {
duplicateFields.add(field.name);
}
fieldMap.set(key, field);
});
if (duplicateFields.size > 0) {
console.warn(`⚠️ 중복 필드명 감지: ${Array.from(duplicateFields).join(", ")}`);
}
// 중복이면 마지막 값으로 덮어씀 (기존 동작 유지)
fieldMap.set(key, field);
});
if (duplicateFields.size > 0) {
console.warn(`⚠️ 중복 필드명 감지: ${Array.from(duplicateFields).join(", ")}`);
console.warn(" → 마지막으로 발견된 필드만 표시됩니다.");
console.warn(" → 다중 소스 사용 시 필드명이 겹치지 않도록 주의하세요!");
const uniqueFields = Array.from(fieldMap.values());
setSourceFields(uniqueFields);
setHasRestAPISource(result.hasRestAPI);
console.log("✅ 최종 소스 필드 목록:", uniqueFields);
};
// 프로시저 노드에 OUT 파라미터가 저장되지 않은 경우, API로 동적 조회
if (procedureNodes.length > 0) {
console.log(`🔄 프로시저 ${procedureNodes.length}개의 반환 필드를 API로 조회`);
applyFields(result.fields);
Promise.all(
procedureNodes.map(async (pn) => {
try {
const res = await getFlowProcedureParameters(
pn.procedureName,
pn.dbSource,
pn.connectionId,
pn.schema
);
if (res.success && res.data) {
return res.data
.filter((p: any) => p.mode === "OUT" || p.mode === "INOUT")
.map((p: any) => ({
name: p.name,
label: `${p.name} (프로시저 반환)`,
sourcePath: pn.sourcePath,
}));
}
} catch (e) {
console.error("프로시저 파라미터 조회 실패:", e);
}
return [];
})
).then((extraFieldArrays) => {
const extraFields = extraFieldArrays.flat();
if (extraFields.length > 0) {
console.log(`✅ 프로시저 반환 필드 ${extraFields.length}개 추가 발견`);
applyFields([...result.fields, ...extraFields]);
}
});
} else {
applyFields(result.fields);
}
const uniqueFields = Array.from(fieldMap.values());
setSourceFields(uniqueFields);
setHasRestAPISource(result.hasRestAPI);
console.log("✅ 최종 소스 필드 목록:", uniqueFields);
console.log("✅ REST API 소스 연결:", result.hasRestAPI);
}, [nodeId, nodes, edges]);
/**

View File

@@ -0,0 +1,641 @@
"use client";
/**
* 프로시저/함수 호출 노드 속성 편집
*/
import { useEffect, useState, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Database, Workflow, RefreshCw, Loader2 } from "lucide-react";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import {
getFlowProcedures,
getFlowProcedureParameters,
} from "@/lib/api/flow";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
import type { ProcedureCallActionNodeData } from "@/types/node-editor";
import type { ProcedureListItem, ProcedureParameterInfo } from "@/types/flowExternalDb";
interface ExternalConnection {
id: number;
connection_name: string;
db_type: string;
}
interface ProcedureCallActionPropertiesProps {
nodeId: string;
data: ProcedureCallActionNodeData;
}
export function ProcedureCallActionProperties({
nodeId,
data,
}: ProcedureCallActionPropertiesProps) {
const { updateNode, nodes, edges } = useFlowEditorStore();
const [displayName, setDisplayName] = useState(
data.displayName || "프로시저 호출"
);
const [dbSource, setDbSource] = useState<"internal" | "external">(
data.dbSource || "internal"
);
const [connectionId, setConnectionId] = useState<number | undefined>(
data.connectionId
);
const [procedureName, setProcedureName] = useState(
data.procedureName || ""
);
const [procedureSchema, setProcedureSchema] = useState(
data.procedureSchema || "public"
);
const [callType, setCallType] = useState<"procedure" | "function">(
data.callType || "function"
);
const [parameters, setParameters] = useState(data.parameters || []);
const [connections, setConnections] = useState<ExternalConnection[]>([]);
const [procedures, setProcedures] = useState<ProcedureListItem[]>([]);
const [loadingProcedures, setLoadingProcedures] = useState(false);
const [loadingParams, setLoadingParams] = useState(false);
const [sourceFields, setSourceFields] = useState<
Array<{ name: string; label?: string }>
>([]);
// 이전 노드에서 소스 필드 목록 수집 (재귀)
useEffect(() => {
const getUpstreamFields = (
targetId: string,
visited = new Set<string>()
): Array<{ name: string; label?: string }> => {
if (visited.has(targetId)) return [];
visited.add(targetId);
const inEdges = edges.filter((e) => e.target === targetId);
const parentNodes = nodes.filter((n) =>
inEdges.some((e) => e.source === n.id)
);
const fields: Array<{ name: string; label?: string }> = [];
for (const pNode of parentNodes) {
if (
pNode.type === "tableSource" ||
pNode.type === "externalDBSource"
) {
const nodeFields =
(pNode.data as any).fields ||
(pNode.data as any).outputFields ||
[];
if (Array.isArray(nodeFields)) {
for (const f of nodeFields) {
const name =
typeof f === "string"
? f
: f.name || f.columnName || f.field;
if (name) {
fields.push({
name,
label: f.label || f.columnLabel || name,
});
}
}
}
} else if (pNode.type === "dataTransform") {
const upper = getUpstreamFields(pNode.id, visited);
fields.push(...upper);
const transforms = (pNode.data as any).transformations;
if (Array.isArray(transforms)) {
for (const t of transforms) {
if (t.targetField) {
fields.push({
name: t.targetField,
label: t.targetFieldLabel || t.targetField,
});
}
}
}
} else if (pNode.type === "formulaTransform") {
const upper = getUpstreamFields(pNode.id, visited);
fields.push(...upper);
const transforms = (pNode.data as any).transformations;
if (Array.isArray(transforms)) {
for (const t of transforms) {
if (t.outputField) {
fields.push({
name: t.outputField,
label: t.outputFieldLabel || t.outputField,
});
}
}
}
} else {
fields.push(...getUpstreamFields(pNode.id, visited));
}
}
return fields;
};
const collected = getUpstreamFields(nodeId);
const unique = Array.from(
new Map(collected.map((f) => [f.name, f])).values()
);
setSourceFields(unique);
}, [nodeId, nodes, edges]);
useEffect(() => {
setDisplayName(data.displayName || "프로시저 호출");
setDbSource(data.dbSource || "internal");
setConnectionId(data.connectionId);
setProcedureName(data.procedureName || "");
setProcedureSchema(data.procedureSchema || "public");
setCallType(data.callType || "function");
setParameters(data.parameters || []);
}, [data]);
// 외부 DB 연결 목록 조회
useEffect(() => {
if (dbSource === "external") {
ExternalDbConnectionAPI.getConnections({ is_active: "true" })
.then((list) =>
setConnections(
list.map((c: any) => ({
id: c.id,
connection_name: c.connection_name,
db_type: c.db_type,
}))
)
)
.catch(console.error);
}
}, [dbSource]);
const updateNodeData = useCallback(
(updates: Partial<ProcedureCallActionNodeData>) => {
updateNode(nodeId, { ...data, ...updates });
},
[nodeId, data, updateNode]
);
// 프로시저 목록 조회
const fetchProcedures = useCallback(async () => {
if (dbSource === "external" && !connectionId) return;
setLoadingProcedures(true);
try {
const res = await getFlowProcedures(
dbSource,
connectionId,
procedureSchema || undefined
);
if (res.success && res.data) {
setProcedures(res.data);
}
} catch (e) {
console.error("프로시저 목록 조회 실패:", e);
} finally {
setLoadingProcedures(false);
}
}, [dbSource, connectionId, procedureSchema]);
// dbSource/connectionId 변경 시 프로시저 목록 자동 조회
useEffect(() => {
if (dbSource === "internal" || (dbSource === "external" && connectionId)) {
fetchProcedures();
}
}, [dbSource, connectionId, fetchProcedures]);
// 프로시저 선택 시 파라미터 조회
const handleProcedureSelect = useCallback(
async (name: string) => {
setProcedureName(name);
const selected = procedures.find((p) => p.name === name);
const newCallType =
selected?.type === "PROCEDURE" ? "procedure" : "function";
setCallType(newCallType);
updateNodeData({
procedureName: name,
callType: newCallType,
procedureSchema,
});
setLoadingParams(true);
try {
const res = await getFlowProcedureParameters(
name,
dbSource,
connectionId,
procedureSchema || undefined
);
if (res.success && res.data) {
const newParams = res.data.map((p: ProcedureParameterInfo) => ({
name: p.name,
dataType: p.dataType,
mode: p.mode,
source: "record_field" as const,
field: "",
value: "",
}));
setParameters(newParams);
updateNodeData({
procedureName: name,
callType: newCallType,
procedureSchema,
parameters: newParams,
});
}
} catch (e) {
console.error("파라미터 조회 실패:", e);
} finally {
setLoadingParams(false);
}
},
[dbSource, connectionId, procedureSchema, procedures, updateNodeData]
);
const handleParamChange = (
index: number,
field: string,
value: string
) => {
const newParams = [...parameters];
(newParams[index] as any)[field] = value;
setParameters(newParams);
updateNodeData({ parameters: newParams });
};
return (
<div className="space-y-4 p-4">
{/* 표시명 */}
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
value={displayName}
onChange={(e) => {
setDisplayName(e.target.value);
updateNodeData({ displayName: e.target.value });
}}
placeholder="프로시저 호출"
className="h-8 text-sm"
/>
</div>
{/* DB 소스 */}
<div className="space-y-2">
<Label className="text-xs font-medium">DB </Label>
<Select
value={dbSource}
onValueChange={(v: "internal" | "external") => {
setDbSource(v);
setConnectionId(undefined);
setProcedureName("");
setParameters([]);
setProcedures([]);
updateNodeData({
dbSource: v,
connectionId: undefined,
connectionName: undefined,
procedureName: "",
parameters: [],
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal"> DB (PostgreSQL)</SelectItem>
<SelectItem value="external"> DB</SelectItem>
</SelectContent>
</Select>
</div>
{/* 외부 DB 연결 선택 */}
{dbSource === "external" && (
<div className="space-y-2">
<Label className="text-xs font-medium"> DB </Label>
<Select
value={connectionId?.toString() || ""}
onValueChange={(v) => {
const id = parseInt(v);
setConnectionId(id);
setProcedureName("");
setParameters([]);
const conn = connections.find((c) => c.id === id);
updateNodeData({
connectionId: id,
connectionName: conn?.connection_name,
procedureName: "",
parameters: [],
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="연결 선택" />
</SelectTrigger>
<SelectContent>
{connections.map((c) => (
<SelectItem key={c.id} value={c.id.toString()}>
{c.connection_name} ({c.db_type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 스키마 */}
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<div className="flex gap-2">
<Input
value={procedureSchema}
onChange={(e) => setProcedureSchema(e.target.value)}
onBlur={() => {
updateNodeData({ procedureSchema });
fetchProcedures();
}}
placeholder="public"
className="h-8 text-sm"
/>
<Button
variant="outline"
size="sm"
className="h-8 w-8 shrink-0 p-0"
onClick={fetchProcedures}
disabled={loadingProcedures}
>
{loadingProcedures ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
</Button>
</div>
</div>
{/* 프로시저 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium">/ </Label>
{loadingProcedures ? (
<div className="flex items-center gap-2 rounded border p-2 text-xs text-gray-500">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<Select
value={procedureName}
onValueChange={handleProcedureSelect}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="프로시저 선택" />
</SelectTrigger>
<SelectContent>
{procedures.map((p) => (
<SelectItem key={`${p.schema}.${p.name}`} value={p.name}>
<div className="flex items-center gap-2">
<span
className={`rounded px-1 py-0.5 text-[10px] font-medium ${
p.type === "FUNCTION"
? "bg-cyan-100 text-cyan-700"
: "bg-violet-100 text-violet-700"
}`}
>
{p.type === "FUNCTION" ? "FN" : "SP"}
</span>
<span className="font-mono text-xs">{p.name}</span>
</div>
</SelectItem>
))}
{procedures.length === 0 && (
<SelectItem value="" disabled>
</SelectItem>
)}
</SelectContent>
</Select>
)}
</div>
{/* 호출 타입 */}
{procedureName && (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={callType}
onValueChange={(v: "procedure" | "function") => {
setCallType(v);
updateNodeData({ callType: v });
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="function">SELECT ()</SelectItem>
<SelectItem value="procedure">CALL ()</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 파라미터 매핑 */}
{procedureName && parameters.length > 0 && (
<div className="space-y-3">
{loadingParams ? (
<div className="flex items-center gap-2 rounded border p-2 text-xs text-gray-500">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<>
{/* IN 파라미터 */}
{parameters.filter((p) => p.mode === "IN" || p.mode === "INOUT")
.length > 0 && (
<div className="space-y-2">
<Label className="flex items-center gap-1.5 text-xs font-medium">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700">
IN
</span>
</Label>
<div className="space-y-2">
{parameters.map((param, idx) => {
if (param.mode !== "IN" && param.mode !== "INOUT")
return null;
return (
<Card key={idx} className="bg-gray-50">
<CardContent className="space-y-2 p-3">
<div className="flex items-center justify-between">
<span className="font-mono text-xs font-medium">
{param.name}
</span>
<span className="rounded bg-gray-200 px-1.5 py-0.5 text-[10px]">
{param.dataType}
</span>
</div>
<Select
value={param.source}
onValueChange={(v) =>
handleParamChange(idx, "source", v)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="record_field">
</SelectItem>
<SelectItem value="static"></SelectItem>
<SelectItem value="step_variable">
</SelectItem>
</SelectContent>
</Select>
{param.source === "record_field" &&
(sourceFields.length > 0 ? (
<Select
value={param.field || ""}
onValueChange={(v) =>
handleParamChange(idx, "field", v)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{sourceFields.map((f) => (
<SelectItem
key={f.name}
value={f.name}
>
<span className="font-mono text-xs">
{f.name}
</span>
{f.label && f.label !== f.name && (
<span className="ml-1 text-[10px] text-gray-400">
({f.label})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={param.field || ""}
onChange={(e) =>
handleParamChange(
idx,
"field",
e.target.value
)
}
placeholder="컬럼명 (이전 노드를 먼저 연결하세요)"
className="h-7 text-xs"
/>
))}
{param.source === "static" && (
<Input
value={param.value || ""}
onChange={(e) =>
handleParamChange(
idx,
"value",
e.target.value
)
}
placeholder="고정값 입력"
className="h-7 text-xs"
/>
)}
{param.source === "step_variable" && (
<Input
value={param.field || ""}
onChange={(e) =>
handleParamChange(
idx,
"field",
e.target.value
)
}
placeholder="변수명"
className="h-7 text-xs"
/>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
)}
{/* OUT 파라미터 (반환 필드) */}
{parameters.filter((p) => p.mode === "OUT" || p.mode === "INOUT")
.length > 0 && (
<div className="space-y-2">
<Label className="flex items-center gap-1.5 text-xs font-medium">
<span className="rounded bg-green-100 px-1.5 py-0.5 text-[10px] text-green-700">
OUT
</span>
<span className="text-[10px] font-normal text-gray-400">
( )
</span>
</Label>
<div className="rounded-md border border-green-200 bg-green-50 p-2">
<div className="space-y-1">
{parameters
.filter(
(p) => p.mode === "OUT" || p.mode === "INOUT"
)
.map((param, idx) => (
<div
key={idx}
className="flex items-center justify-between rounded bg-white px-2 py-1.5"
>
<span className="font-mono text-xs font-medium text-green-700">
{param.name}
</span>
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500">
{param.dataType}
</span>
</div>
))}
</div>
</div>
</div>
)}
</>
)}
</div>
)}
{/* 안내 메시지 */}
<Card className="bg-violet-50">
<CardContent className="p-3 text-xs text-violet-700">
<div className="mb-1 flex items-center gap-1 font-medium">
<Workflow className="h-3 w-3" />
</div>
<p>
. .
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -17,6 +17,7 @@ import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { tableTypeApi } from "@/lib/api/screen";
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
import { getFlowProcedureParameters } from "@/lib/api/flow";
import type { UpdateActionNodeData } from "@/types/node-editor";
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
@@ -165,6 +166,13 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
useEffect(() => {
const procedureNodes: Array<{
procedureName: string;
dbSource: "internal" | "external";
connectionId?: number;
schema?: string;
}> = [];
const getAllSourceFields = (
targetNodeId: string,
visitedNodes = new Set<string>(),
@@ -310,7 +318,33 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
}
}
// 5통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
// 5프로시저 호출 노드: 상위 필드 + OUT 파라미터 추가
else if (node.type === "procedureCallAction") {
const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
const nodeData = node.data as any;
const procParams = nodeData.parameters;
let hasOutParams = false;
if (Array.isArray(procParams)) {
for (const p of procParams) {
if (p.mode === "OUT" || p.mode === "INOUT") {
hasOutParams = true;
fields.push({ name: p.name, label: `${p.name} (프로시저 반환)` });
}
}
}
if (!hasOutParams && nodeData.procedureName) {
procedureNodes.push({
procedureName: nodeData.procedureName,
dbSource: nodeData.dbSource || "internal",
connectionId: nodeData.connectionId,
schema: nodeData.procedureSchema || "public",
});
}
fields.push({ name: "_procedureReturn", label: "프로시저 반환값" });
}
// 6⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
else {
const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields);
@@ -323,11 +357,33 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
const result = getAllSourceFields(nodeId);
// 중복 제거
const uniqueFields = Array.from(new Map(result.fields.map((field) => [field.name, field])).values());
const applyFields = (allFields: typeof result.fields) => {
const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values());
setSourceFields(uniqueFields);
setHasRestAPISource(result.hasRestAPI);
};
setSourceFields(uniqueFields);
setHasRestAPISource(result.hasRestAPI);
if (procedureNodes.length > 0) {
applyFields(result.fields);
Promise.all(
procedureNodes.map(async (pn) => {
try {
const res = await getFlowProcedureParameters(pn.procedureName, pn.dbSource, pn.connectionId, pn.schema);
if (res.success && res.data) {
return res.data
.filter((p: any) => p.mode === "OUT" || p.mode === "INOUT")
.map((p: any) => ({ name: p.name, label: `${p.name} (프로시저 반환)` }));
}
} catch (e) { console.error("프로시저 파라미터 조회 실패:", e); }
return [];
})
).then((extraFieldArrays) => {
const extraFields = extraFieldArrays.flat();
if (extraFields.length > 0) applyFields([...result.fields, ...extraFields]);
});
} else {
applyFields(result.fields);
}
}, [nodeId, nodes, edges]);
const loadTables = async () => {

View File

@@ -17,6 +17,7 @@ import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { tableTypeApi } from "@/lib/api/screen";
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
import { getFlowProcedureParameters } from "@/lib/api/flow";
import type { UpsertActionNodeData } from "@/types/node-editor";
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
@@ -148,6 +149,13 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
useEffect(() => {
const procedureNodes: Array<{
procedureName: string;
dbSource: "internal" | "external";
connectionId?: number;
schema?: string;
}> = [];
const getAllSourceFields = (
targetNodeId: string,
visitedNodes = new Set<string>(),
@@ -293,7 +301,33 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
}
}
// 5통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
// 5프로시저 호출 노드: 상위 필드 + OUT 파라미터 추가
else if (node.type === "procedureCallAction") {
const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
const nodeData = node.data as any;
const procParams = nodeData.parameters;
let hasOutParams = false;
if (Array.isArray(procParams)) {
for (const p of procParams) {
if (p.mode === "OUT" || p.mode === "INOUT") {
hasOutParams = true;
fields.push({ name: p.name, label: `${p.name} (프로시저 반환)` });
}
}
}
if (!hasOutParams && nodeData.procedureName) {
procedureNodes.push({
procedureName: nodeData.procedureName,
dbSource: nodeData.dbSource || "internal",
connectionId: nodeData.connectionId,
schema: nodeData.procedureSchema || "public",
});
}
fields.push({ name: "_procedureReturn", label: "프로시저 반환값" });
}
// 6⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
else {
const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields);
@@ -306,11 +340,33 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
const result = getAllSourceFields(nodeId);
// 중복 제거
const uniqueFields = Array.from(new Map(result.fields.map((field) => [field.name, field])).values());
const applyFields = (allFields: typeof result.fields) => {
const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values());
setSourceFields(uniqueFields);
setHasRestAPISource(result.hasRestAPI);
};
setSourceFields(uniqueFields);
setHasRestAPISource(result.hasRestAPI);
if (procedureNodes.length > 0) {
applyFields(result.fields);
Promise.all(
procedureNodes.map(async (pn) => {
try {
const res = await getFlowProcedureParameters(pn.procedureName, pn.dbSource, pn.connectionId, pn.schema);
if (res.success && res.data) {
return res.data
.filter((p: any) => p.mode === "OUT" || p.mode === "INOUT")
.map((p: any) => ({ name: p.name, label: `${p.name} (프로시저 반환)` }));
}
} catch (e) { console.error("프로시저 파라미터 조회 실패:", e); }
return [];
})
).then((extraFieldArrays) => {
const extraFields = extraFieldArrays.flat();
if (extraFields.length > 0) applyFields([...result.fields, ...extraFields]);
});
} else {
applyFields(result.fields);
}
}, [nodeId, nodes, edges]);
// 🔥 외부 커넥션 로딩 함수

View File

@@ -132,6 +132,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [
category: "external",
color: "#06B6D4", // 시안
},
{
type: "procedureCallAction",
label: "프로시저 호출",
icon: "",
description: "DB 프로시저/함수를 호출합니다",
category: "external",
color: "#8B5CF6", // 보라색
},
// ========================================================================
// 유틸리티