플로우 외부db연결

This commit is contained in:
kjs
2025-10-20 17:50:27 +09:00
parent 7d8abc0449
commit 1f12df2f79
16 changed files with 3711 additions and 40 deletions

View File

@@ -0,0 +1,384 @@
"use client";
import React, { useState, useEffect } 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 {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import { flowExternalDbApi } from "@/lib/api/flowExternalDb";
import {
FlowExternalDbConnection,
CreateFlowExternalDbConnectionRequest,
UpdateFlowExternalDbConnectionRequest,
DB_TYPE_OPTIONS,
getDbTypeLabel,
} from "@/types/flowExternalDb";
import { Plus, Pencil, Trash2, TestTube, Loader2 } from "lucide-react";
import { Switch } from "@/components/ui/switch";
export default function FlowExternalDbPage() {
const { toast } = useToast();
const [connections, setConnections] = useState<FlowExternalDbConnection[]>([]);
const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [editingConnection, setEditingConnection] = useState<FlowExternalDbConnection | null>(null);
const [testingId, setTestingId] = useState<number | null>(null);
// 폼 상태
const [formData, setFormData] = useState<
CreateFlowExternalDbConnectionRequest | UpdateFlowExternalDbConnectionRequest
>({
name: "",
description: "",
dbType: "postgresql",
host: "",
port: 5432,
databaseName: "",
username: "",
password: "",
sslEnabled: false,
});
useEffect(() => {
loadConnections();
}, []);
const loadConnections = async () => {
try {
setLoading(true);
const response = await flowExternalDbApi.getAll();
if (response.success) {
setConnections(response.data);
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "외부 DB 연결 목록 조회 실패",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingConnection(null);
setFormData({
name: "",
description: "",
dbType: "postgresql",
host: "",
port: 5432,
databaseName: "",
username: "",
password: "",
sslEnabled: false,
});
setShowDialog(true);
};
const handleEdit = (connection: FlowExternalDbConnection) => {
setEditingConnection(connection);
setFormData({
name: connection.name,
description: connection.description,
host: connection.host,
port: connection.port,
databaseName: connection.databaseName,
username: connection.username,
password: "", // 비밀번호는 비워둠
sslEnabled: connection.sslEnabled,
isActive: connection.isActive,
});
setShowDialog(true);
};
const handleSave = async () => {
try {
if (editingConnection) {
// 수정
await flowExternalDbApi.update(editingConnection.id, formData);
toast({ title: "성공", description: "외부 DB 연결이 수정되었습니다" });
} else {
// 생성
await flowExternalDbApi.create(formData as CreateFlowExternalDbConnectionRequest);
toast({ title: "성공", description: "외부 DB 연결이 생성되었습니다" });
}
setShowDialog(false);
loadConnections();
} catch (error: any) {
toast({
title: "오류",
description: error.message,
variant: "destructive",
});
}
};
const handleDelete = async (id: number, name: string) => {
if (!confirm(`"${name}" 연결을 삭제하시겠습니까?`)) {
return;
}
try {
await flowExternalDbApi.delete(id);
toast({ title: "성공", description: "외부 DB 연결이 삭제되었습니다" });
loadConnections();
} catch (error: any) {
toast({
title: "오류",
description: error.message,
variant: "destructive",
});
}
};
const handleTestConnection = async (id: number, name: string) => {
setTestingId(id);
try {
const result = await flowExternalDbApi.testConnection(id);
toast({
title: result.success ? "연결 성공" : "연결 실패",
description: result.message,
variant: result.success ? "default" : "destructive",
});
} catch (error: any) {
toast({
title: "오류",
description: error.message,
variant: "destructive",
});
} finally {
setTestingId(null);
}
};
return (
<div className="container mx-auto py-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"> DB </h1>
<p className="text-muted-foreground mt-1 text-sm"> </p>
</div>
<Button onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : connections.length === 0 ? (
<div className="bg-muted/50 rounded-lg border py-12 text-center">
<p className="text-muted-foreground"> DB </p>
<Button onClick={handleCreate} className="mt-4">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
) : (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>DB </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connections.map((conn) => (
<TableRow key={conn.id}>
<TableCell className="font-medium">
<div>
<div>{conn.name}</div>
{conn.description && <div className="text-muted-foreground text-xs">{conn.description}</div>}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{getDbTypeLabel(conn.dbType)}</Badge>
</TableCell>
<TableCell className="font-mono text-sm">
{conn.host}:{conn.port}
</TableCell>
<TableCell className="font-mono text-sm">{conn.databaseName}</TableCell>
<TableCell>
<Badge variant={conn.isActive ? "default" : "secondary"}>{conn.isActive ? "활성" : "비활성"}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleTestConnection(conn.id, conn.name)}
disabled={testingId === conn.id}
>
{testingId === conn.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube className="h-4 w-4" />
)}
</Button>
<Button variant="ghost" size="sm" onClick={() => handleEdit(conn)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(conn.id, conn.name)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 생성/수정 다이얼로그 */}
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editingConnection ? "외부 DB 연결 수정" : "새 외부 DB 연결 추가"}</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="예: 운영_PostgreSQL"
/>
</div>
<div className="col-span-2">
<Label htmlFor="description"></Label>
<Input
id="description"
value={formData.description || ""}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="연결에 대한 설명"
/>
</div>
<div>
<Label htmlFor="dbType">DB *</Label>
<Select
value={formData.dbType}
onValueChange={(value: any) => setFormData({ ...formData, dbType: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DB_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<div className="flex-1">
<Label htmlFor="host"> *</Label>
<Input
id="host"
value={formData.host}
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
placeholder="localhost"
/>
</div>
<div className="w-24">
<Label htmlFor="port"> *</Label>
<Input
id="port"
type="number"
value={formData.port}
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) || 0 })}
/>
</div>
</div>
<div className="col-span-2">
<Label htmlFor="databaseName"> *</Label>
<Input
id="databaseName"
value={formData.databaseName}
onChange={(e) => setFormData({ ...formData, databaseName: e.target.value })}
placeholder="mydb"
/>
</div>
<div>
<Label htmlFor="username"> *</Label>
<Input
id="username"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
placeholder="dbuser"
/>
</div>
<div>
<Label htmlFor="password"> {editingConnection && "(변경 시에만 입력)"}</Label>
<Input
id="password"
type="password"
value={formData.password || ""}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder={editingConnection ? "변경하지 않으려면 비워두세요" : "비밀번호"}
/>
</div>
<div className="col-span-2 flex items-center gap-2">
<Switch
id="sslEnabled"
checked={formData.sslEnabled}
onCheckedChange={(checked) => setFormData({ ...formData, sslEnabled: checked })}
/>
<Label htmlFor="sslEnabled">SSL </Label>
</div>
{editingConnection && (
<div className="col-span-2 flex items-center gap-2">
<Switch
id="isActive"
checked={(formData as UpdateFlowExternalDbConnectionRequest).isActive ?? true}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
/>
<Label htmlFor="isActive"></Label>
</div>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowDialog(false)}>
</Button>
<Button onClick={handleSave}>{editingConnection ? "수정" : "생성"}</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -3,7 +3,7 @@
* 선택된 단계의 속성 편집
*/
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { X, Trash2, Save, Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -18,6 +18,15 @@ import { FlowStep } from "@/types/flow";
import { FlowConditionBuilder } from "./FlowConditionBuilder";
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";
import { flowExternalDbApi } from "@/lib/api/flowExternalDb";
import {
FlowExternalDbConnection,
FlowExternalDbIntegrationConfig,
INTEGRATION_TYPE_OPTIONS,
OPERATION_OPTIONS,
} from "@/types/flowExternalDb";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
interface FlowStepPanelProps {
step: FlowStep;
@@ -39,17 +48,29 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
statusValue: step.statusValue || "",
targetTable: step.targetTable || "",
fieldMappings: step.fieldMappings || {},
// 외부 연동 필드
integrationType: step.integrationType || "internal",
integrationConfig: step.integrationConfig,
});
const [tableList, setTableList] = useState<any[]>([]);
const [loadingTables, setLoadingTables] = useState(true);
const [openTableCombobox, setOpenTableCombobox] = useState(false);
// 외부 DB 테이블 목록
const [externalTableList, setExternalTableList] = useState<string[]>([]);
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID
// 컬럼 목록 (상태 컬럼 선택용)
const [columns, setColumns] = useState<any[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [openStatusColumnCombobox, setOpenStatusColumnCombobox] = useState(false);
// 외부 DB 연결 목록
const [externalConnections, setExternalConnections] = useState<FlowExternalDbConnection[]>([]);
const [loadingConnections, setLoadingConnections] = useState(false);
// 테이블 목록 조회
useEffect(() => {
const loadTables = async () => {
@@ -68,8 +89,135 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
loadTables();
}, []);
// 외부 DB 연결 목록 조회 (JWT 토큰 사용)
useEffect(() => {
setFormData({
const loadConnections = async () => {
try {
setLoadingConnections(true);
// localStorage에서 JWT 토큰 가져오기
const token = localStorage.getItem("authToken");
if (!token) {
console.warn("토큰이 없습니다. 외부 DB 연결 목록을 조회할 수 없습니다.");
setExternalConnections([]);
return;
}
const response = await fetch("/api/external-db-connections/control/active", {
credentials: "include",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}).catch((err) => {
console.warn("외부 DB 연결 목록 fetch 실패:", err);
return null;
});
if (response && response.ok) {
const result = await response.json();
if (result.success && result.data) {
// 메인 DB 제외하고 외부 DB만 필터링
const externalOnly = result.data.filter((conn: any) => conn.id !== 0);
setExternalConnections(externalOnly);
} else {
setExternalConnections([]);
}
} else {
// 401 오류 시 빈 배열로 처리 (리다이렉트 방지)
console.warn("외부 DB 연결 목록 조회 실패:", response?.status || "네트워크 오류");
setExternalConnections([]);
}
} catch (error: any) {
console.error("Failed to load external connections:", error);
setExternalConnections([]);
} finally {
setLoadingConnections(false);
}
};
loadConnections();
}, []);
// 외부 DB 선택 시 해당 DB의 테이블 목록 조회 (JWT 토큰 사용)
useEffect(() => {
const loadExternalTables = async () => {
console.log("🔍 loadExternalTables triggered, selectedDbSource:", selectedDbSource);
if (selectedDbSource === "internal" || typeof selectedDbSource !== "number") {
console.log("⚠️ Skipping external table load (internal or not a number)");
setExternalTableList([]);
return;
}
console.log("📡 Loading external tables for connection ID:", selectedDbSource);
try {
setLoadingExternalTables(true);
// localStorage에서 JWT 토큰 가져오기
const token = localStorage.getItem("authToken");
if (!token) {
console.warn("토큰이 없습니다. 외부 DB 테이블 목록을 조회할 수 없습니다.");
setExternalTableList([]);
return;
}
// 기존 multi-connection API 사용 (JWT 토큰 포함)
const response = await fetch(`/api/multi-connection/connections/${selectedDbSource}/tables`, {
credentials: "include",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}).catch((err) => {
console.warn("외부 DB 테이블 목록 fetch 실패:", err);
return null;
});
if (response && response.ok) {
const result = await response.json();
console.log("✅ External tables API response:", result);
console.log("📊 result.data type:", typeof result.data, "isArray:", Array.isArray(result.data));
console.log("📊 result.data:", JSON.stringify(result.data, null, 2));
if (result.success && result.data) {
// 데이터 형식이 다를 수 있으므로 변환
const tableNames = result.data.map((t: any) => {
console.log("🔍 Processing item:", t, "type:", typeof t);
// tableName (camelCase), table_name, tablename, name 모두 지원
return typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name;
});
console.log("📋 Processed table names:", tableNames);
setExternalTableList(tableNames);
} else {
console.warn("❌ No data in response or success=false");
setExternalTableList([]);
}
} else {
// 인증 오류 시에도 빈 배열로 처리 (리다이렉트 방지)
console.warn(`외부 DB 테이블 목록 조회 실패: ${response?.status || "네트워크 오류"}`);
setExternalTableList([]);
}
} catch (error) {
console.error("외부 DB 테이블 목록 조회 오류:", error);
setExternalTableList([]);
} finally {
setLoadingExternalTables(false);
}
};
loadExternalTables();
}, [selectedDbSource]);
useEffect(() => {
console.log("🔄 Initializing formData from step:", {
id: step.id,
stepName: step.stepName,
statusColumn: step.statusColumn,
statusValue: step.statusValue,
});
const newFormData = {
stepName: step.stepName,
tableName: step.tableName || "",
conditionJson: step.conditionJson,
@@ -79,8 +227,14 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
statusValue: step.statusValue || "",
targetTable: step.targetTable || "",
fieldMappings: step.fieldMappings || {},
});
}, [step]);
// 외부 연동 필드
integrationType: step.integrationType || "internal",
integrationConfig: step.integrationConfig,
};
console.log("✅ Setting formData:", newFormData);
setFormData(newFormData);
}, [step.id]); // step 전체가 아닌 step.id만 의존성으로 설정
// 테이블 선택 시 컬럼 로드
useEffect(() => {
@@ -114,10 +268,21 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
loadColumns();
}, [formData.tableName]);
// formData의 최신 값을 항상 참조하기 위한 ref
const formDataRef = useRef(formData);
// formData가 변경될 때마다 ref 업데이트
useEffect(() => {
formDataRef.current = formData;
}, [formData]);
// 저장
const handleSave = async () => {
const handleSave = useCallback(async () => {
const currentFormData = formDataRef.current;
console.log("🚀 handleSave called, formData:", JSON.stringify(currentFormData, null, 2));
try {
const response = await updateFlowStep(step.id, formData);
const response = await updateFlowStep(step.id, currentFormData);
console.log("📡 API response:", response);
if (response.success) {
toast({
title: "저장 완료",
@@ -139,7 +304,7 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
variant: "destructive",
});
}
};
}, [step.id, onUpdate, onClose, toast]);
// 삭제
const handleDelete = async () => {
@@ -203,6 +368,34 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
<Input value={step.stepOrder} disabled />
</div>
{/* DB 소스 선택 */}
<div>
<Label> </Label>
<Select
value={selectedDbSource.toString()}
onValueChange={(value) => {
const dbSource = value === "internal" ? "internal" : parseInt(value);
setSelectedDbSource(dbSource);
// DB 소스 변경 시 테이블 선택 초기화
setFormData({ ...formData, tableName: "" });
}}
>
<SelectTrigger>
<SelectValue placeholder="데이터베이스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal"> </SelectItem>
{externalConnections.map((conn: any) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.connection_name} ({conn.db_type?.toUpperCase()})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
{/* 테이블 선택 */}
<div>
<Label> </Label>
<Popover open={openTableCombobox} onOpenChange={setOpenTableCombobox}>
@@ -212,50 +405,79 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
role="combobox"
aria-expanded={openTableCombobox}
className="w-full justify-between"
disabled={loadingTables}
disabled={loadingTables || (selectedDbSource !== "internal" && loadingExternalTables)}
>
{formData.tableName
? tableList.find((table) => table.tableName === formData.tableName)?.displayName ||
formData.tableName
: loadingTables
? selectedDbSource === "internal"
? tableList.find((table) => table.tableName === formData.tableName)?.displayName ||
formData.tableName
: formData.tableName
: loadingTables || loadingExternalTables
? "로딩 중..."
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{tableList.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(currentValue) => {
setFormData({ ...formData, tableName: currentValue });
setOpenTableCombobox(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.tableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.description && <span className="text-xs text-gray-500">{table.description}</span>}
</div>
</CommandItem>
))}
{selectedDbSource === "internal"
? // 내부 DB 테이블 목록
tableList.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(currentValue) => {
setFormData({ ...formData, tableName: currentValue });
setOpenTableCombobox(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.tableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.description && (
<span className="text-xs text-gray-500">{table.description}</span>
)}
</div>
</CommandItem>
))
: // 외부 DB 테이블 목록 (문자열 배열)
externalTableList.map((tableName, index) => (
<CommandItem
key={`external-${selectedDbSource}-${tableName}-${index}`}
value={tableName}
onSelect={(currentValue) => {
setFormData({ ...formData, tableName: currentValue });
setOpenTableCombobox(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.tableName === tableName ? "opacity-100" : "opacity-0",
)}
/>
<div>{tableName}</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-gray-500"> </p>
<p className="mt-1 text-xs text-gray-500">
{selectedDbSource === "internal"
? "이 단계에서 조건을 적용할 테이블을 선택합니다"
: "외부 데이터베이스의 테이블을 선택합니다"}
</p>
</div>
</CardContent>
</Card>
@@ -382,7 +604,12 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
<Label> </Label>
<Input
value={formData.statusValue}
onChange={(e) => setFormData({ ...formData, statusValue: e.target.value })}
onChange={(e) => {
const newValue = e.target.value;
console.log("💡 statusValue onChange:", newValue);
setFormData({ ...formData, statusValue: newValue });
console.log("✅ Updated formData:", { ...formData, statusValue: newValue });
}}
placeholder="예: approved"
/>
<p className="mt-1 text-xs text-gray-500"> </p>
@@ -423,6 +650,228 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
</CardContent>
</Card>
{/* 외부 DB 연동 설정 */}
<Card>
<CardHeader>
<CardTitle> DB </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label> </Label>
<Select
value={formData.integrationType}
onValueChange={(value: any) => {
setFormData({ ...formData, integrationType: value });
// 타입 변경 시 config 초기화
if (value === "internal") {
setFormData((prev) => ({ ...prev, integrationConfig: undefined }));
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INTEGRATION_TYPE_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value !== "internal" && opt.value !== "external_db"}
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 외부 DB 연동 설정 */}
{formData.integrationType === "external_db" && (
<div className="space-y-4 rounded-lg border p-4">
{externalConnections.length === 0 ? (
<div className="rounded-md bg-yellow-50 p-3">
<p className="text-sm text-yellow-900">
DB . DB .
</p>
</div>
) : (
<>
<div>
<Label> DB </Label>
<Select
value={formData.integrationConfig?.connectionId?.toString() || ""}
onValueChange={(value) => {
const connectionId = parseInt(value);
setFormData({
...formData,
integrationConfig: {
type: "external_db",
connectionId,
operation: "update",
tableName: "",
updateFields: {},
whereCondition: {},
},
});
}}
>
<SelectTrigger>
<SelectValue placeholder="연결 선택" />
</SelectTrigger>
<SelectContent>
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.name} ({conn.dbType})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{formData.integrationConfig?.connectionId && (
<>
<div>
<Label> </Label>
<Select
value={formData.integrationConfig.operation}
onValueChange={(value: any) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
operation: value,
},
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Input
value={formData.integrationConfig.tableName}
onChange={(e) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
tableName: e.target.value,
},
})
}
placeholder="예: orders"
/>
</div>
{formData.integrationConfig.operation === "custom" ? (
<div>
<Label> </Label>
<Textarea
value={formData.integrationConfig.customQuery || ""}
onChange={(e) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
customQuery: e.target.value,
},
})
}
placeholder="UPDATE orders SET status = 'approved' WHERE id = {{dataId}}"
rows={4}
className="font-mono text-sm"
/>
<p className="mt-1 text-xs text-gray-500">
릿 : {`{{dataId}}, {{currentUser}}, {{currentTimestamp}}`}
</p>
</div>
) : (
<>
{(formData.integrationConfig.operation === "update" ||
formData.integrationConfig.operation === "insert") && (
<div>
<Label> (JSON)</Label>
<Textarea
value={JSON.stringify(formData.integrationConfig.updateFields || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
updateFields: parsed,
},
});
} catch (err) {
// JSON 파싱 실패 시 무시
}
}}
placeholder='{"status": "approved", "updated_by": "{{currentUser}}"}'
rows={4}
className="font-mono text-sm"
/>
</div>
)}
{(formData.integrationConfig.operation === "update" ||
formData.integrationConfig.operation === "delete") && (
<div>
<Label>WHERE (JSON)</Label>
<Textarea
value={JSON.stringify(formData.integrationConfig.whereCondition || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
whereCondition: parsed,
},
});
} catch (err) {
// JSON 파싱 실패 시 무시
}
}}
placeholder='{"id": "{{dataId}}"}'
rows={3}
className="font-mono text-sm"
/>
</div>
)}
</>
)}
<div className="rounded-md bg-blue-50 p-3">
<p className="text-sm text-blue-900">
💡 릿 :
<br /> {`{{dataId}}`} - ID
<br /> {`{{currentUser}}`} -
<br /> {`{{currentTimestamp}}`} -
</p>
</div>
</>
)}
</>
)}
</div>
)}
</CardContent>
</Card>
{/* 액션 버튼 */}
<div className="flex gap-2">
<Button className="flex-1" onClick={handleSave}>

View File

@@ -0,0 +1,139 @@
/**
* 플로우 전용 외부 DB 연결 API
*/
import {
FlowExternalDbConnection,
CreateFlowExternalDbConnectionRequest,
UpdateFlowExternalDbConnectionRequest,
FlowExternalDbConnectionListResponse,
FlowExternalDbConnectionResponse,
FlowExternalDbConnectionTestResponse,
} from "@/types/flowExternalDb";
const API_BASE = "/api/flow-external-db";
/**
* 외부 DB 연결 API
*/
export const flowExternalDbApi = {
/**
* 모든 외부 DB 연결 조회
*/
async getAll(activeOnly: boolean = false): Promise<FlowExternalDbConnectionListResponse> {
const query = activeOnly ? "?activeOnly=true" : "";
try {
const response = await fetch(`${API_BASE}${query}`, {
credentials: "include",
});
if (!response.ok) {
// 오류 발생 시 빈 목록 반환 (조용히 실패)
console.warn(`외부 DB 연결 목록 조회 실패: ${response.status} ${response.statusText}`);
return { success: false, data: [] };
}
return response.json();
} catch (error) {
// 네트워크 오류 등 예외 발생 시에도 빈 목록 반환
console.error("외부 DB 연결 목록 조회 오류:", error);
return { success: false, data: [] };
}
},
/**
* 특정 외부 DB 연결 조회
*/
async getById(id: number): Promise<FlowExternalDbConnectionResponse> {
const response = await fetch(`${API_BASE}/${id}`, {
credentials: "include",
});
if (!response.ok) {
throw new Error(`외부 DB 연결 조회 실패: ${response.statusText}`);
}
return response.json();
},
/**
* 새 외부 DB 연결 생성
*/
async create(request: CreateFlowExternalDbConnectionRequest): Promise<FlowExternalDbConnectionResponse> {
const response = await fetch(API_BASE, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "외부 DB 연결 생성 실패");
}
return response.json();
},
/**
* 외부 DB 연결 수정
*/
async update(id: number, request: UpdateFlowExternalDbConnectionRequest): Promise<FlowExternalDbConnectionResponse> {
const response = await fetch(`${API_BASE}/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "외부 DB 연결 수정 실패");
}
return response.json();
},
/**
* 외부 DB 연결 삭제
*/
async delete(id: number): Promise<{ success: boolean; message: string }> {
const response = await fetch(`${API_BASE}/${id}`, {
method: "DELETE",
credentials: "include",
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "외부 DB 연결 삭제 실패");
}
return response.json();
},
/**
* 외부 DB 연결 테스트
*/
async testConnection(id: number): Promise<FlowExternalDbConnectionTestResponse> {
const response = await fetch(`${API_BASE}/${id}/test`, {
method: "POST",
credentials: "include",
});
const result = await response.json();
if (!response.ok) {
return {
success: false,
message: result.message || "연결 테스트 실패",
};
}
return result;
},
};

View File

@@ -0,0 +1,149 @@
/**
* 플로우 전용 외부 DB 연동 타입 정의
* (기존 제어관리 외부 DB와 별도)
*/
// ==================== 연동 타입 ====================
export type FlowIntegrationType = "internal" | "external_db" | "rest_api" | "webhook" | "hybrid";
// ==================== 외부 DB 연결 ====================
export interface FlowExternalDbConnection {
id: number;
name: string;
description?: string;
dbType: "postgresql" | "mysql" | "mssql" | "oracle";
host: string;
port: number;
databaseName: string;
username: string;
passwordEncrypted: string; // 암호화된 비밀번호 (화면에는 표시하지 않음)
sslEnabled: boolean;
connectionOptions?: Record<string, any>;
isActive: boolean;
createdBy?: string;
updatedBy?: string;
createdAt: string;
updatedAt: string;
}
export interface CreateFlowExternalDbConnectionRequest {
name: string;
description?: string;
dbType: "postgresql" | "mysql" | "mssql" | "oracle";
host: string;
port: number;
databaseName: string;
username: string;
password: string; // 평문 비밀번호 (생성 시에만 사용)
sslEnabled?: boolean;
connectionOptions?: Record<string, any>;
}
export interface UpdateFlowExternalDbConnectionRequest {
name?: string;
description?: string;
host?: string;
port?: number;
databaseName?: string;
username?: string;
password?: string; // 평문 비밀번호 (변경 시에만)
sslEnabled?: boolean;
connectionOptions?: Record<string, any>;
isActive?: boolean;
}
// ==================== 외부 DB 연동 설정 ====================
export interface FlowExternalDbIntegrationConfig {
type: "external_db";
connectionId: number; // 연결 ID
operation: "update" | "insert" | "delete" | "custom";
tableName: string;
updateFields?: Record<string, any>; // 업데이트할 필드
whereCondition?: Record<string, any>; // WHERE 조건
customQuery?: string; // 커스텀 쿼리
}
// 연동 설정 통합 타입
export type FlowIntegrationConfig = FlowExternalDbIntegrationConfig;
// ==================== 연동 로그 ====================
export interface FlowIntegrationLog {
id: number;
flowDefinitionId: number;
stepId: number;
dataId?: string;
integrationType: string;
connectionId?: number;
requestPayload?: Record<string, any>;
responsePayload?: Record<string, any>;
status: "success" | "failed" | "timeout" | "rollback";
errorMessage?: string;
executionTimeMs?: number;
executedBy?: string;
executedAt: string;
}
// ==================== API 응답 ====================
export interface FlowExternalDbConnectionListResponse {
success: boolean;
data: FlowExternalDbConnection[];
message?: string;
}
export interface FlowExternalDbConnectionResponse {
success: boolean;
data?: FlowExternalDbConnection;
message?: string;
error?: string;
}
export interface FlowExternalDbConnectionTestResponse {
success: boolean;
message: string;
}
// ==================== UI 관련 ====================
export const DB_TYPE_OPTIONS = [
{ value: "postgresql", label: "PostgreSQL" },
{ value: "mysql", label: "MySQL" },
{ value: "mssql", label: "MS SQL Server" },
{ value: "oracle", label: "Oracle" },
] as const;
export const OPERATION_OPTIONS = [
{ value: "update", label: "업데이트 (UPDATE)" },
{ value: "insert", label: "삽입 (INSERT)" },
{ value: "delete", label: "삭제 (DELETE)" },
{ value: "custom", label: "커스텀 쿼리" },
] as const;
export const INTEGRATION_TYPE_OPTIONS = [
{ value: "internal", label: "내부 DB (기본)" },
{ value: "external_db", label: "외부 DB 연동" },
{ value: "rest_api", label: "REST API (추후 지원)" },
{ value: "webhook", label: "Webhook (추후 지원)" },
{ value: "hybrid", label: "복합 연동 (추후 지원)" },
] as const;
// ==================== 헬퍼 함수 ====================
export function getDbTypeLabel(dbType: string): string {
const option = DB_TYPE_OPTIONS.find((opt) => opt.value === dbType);
return option?.label || dbType;
}
export function getOperationLabel(operation: string): string {
const option = OPERATION_OPTIONS.find((opt) => opt.value === operation);
return option?.label || operation;
}
export function getIntegrationTypeLabel(type: string): string {
const option = INTEGRATION_TYPE_OPTIONS.find((opt) => opt.value === type);
return option?.label || type;
}