ERP-node/frontend/components/dataflow/node-editor/panels/properties/ProcedureCallActionProperti...

642 lines
23 KiB
TypeScript

"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>
);
}