2025-10-20 10:55:33 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 플로우 단계 설정 패널
|
|
|
|
|
|
* 선택된 단계의 속성 편집
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2025-10-20 17:50:27 +09:00
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
2025-10-20 10:55:33 +09:00
|
|
|
|
import { X, Trash2, Save, Check, ChevronsUpDown } from "lucide-react";
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
2025-10-20 15:53:00 +09:00
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
2025-10-20 10:55:33 +09:00
|
|
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
|
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
|
|
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
|
|
import { updateFlowStep, deleteFlowStep } from "@/lib/api/flow";
|
|
|
|
|
|
import { FlowStep } from "@/types/flow";
|
|
|
|
|
|
import { FlowConditionBuilder } from "./FlowConditionBuilder";
|
2025-10-20 15:53:00 +09:00
|
|
|
|
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
|
2025-10-20 10:55:33 +09:00
|
|
|
|
import { cn } from "@/lib/utils";
|
2025-10-20 17:50:27 +09:00
|
|
|
|
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";
|
2025-10-20 10:55:33 +09:00
|
|
|
|
|
|
|
|
|
|
interface FlowStepPanelProps {
|
|
|
|
|
|
step: FlowStep;
|
|
|
|
|
|
flowId: number;
|
2025-10-21 13:19:18 +09:00
|
|
|
|
flowTableName?: string; // 플로우 정의에서 선택한 테이블명
|
|
|
|
|
|
flowDbSourceType?: "internal" | "external"; // 플로우의 DB 소스 타입
|
|
|
|
|
|
flowDbConnectionId?: number; // 플로우의 외부 DB 연결 ID
|
2025-10-20 10:55:33 +09:00
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
onUpdate: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-21 13:19:18 +09:00
|
|
|
|
export function FlowStepPanel({
|
|
|
|
|
|
step,
|
|
|
|
|
|
flowId,
|
|
|
|
|
|
flowTableName,
|
|
|
|
|
|
flowDbSourceType = "internal",
|
|
|
|
|
|
flowDbConnectionId,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
onUpdate,
|
|
|
|
|
|
}: FlowStepPanelProps) {
|
2025-10-20 10:55:33 +09:00
|
|
|
|
const { toast } = useToast();
|
|
|
|
|
|
|
2025-10-21 13:19:18 +09:00
|
|
|
|
console.log("🎯 FlowStepPanel Props:", {
|
|
|
|
|
|
stepTableName: step.tableName,
|
|
|
|
|
|
flowTableName,
|
|
|
|
|
|
flowDbSourceType,
|
|
|
|
|
|
flowDbConnectionId,
|
|
|
|
|
|
final: step.tableName || flowTableName || "",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-10-20 10:55:33 +09:00
|
|
|
|
const [formData, setFormData] = useState({
|
|
|
|
|
|
stepName: step.stepName,
|
2025-10-21 13:19:18 +09:00
|
|
|
|
tableName: step.tableName || flowTableName || "", // 플로우 테이블명 우선 사용 (신규 방식)
|
2025-10-20 10:55:33 +09:00
|
|
|
|
conditionJson: step.conditionJson,
|
2025-10-20 15:53:00 +09:00
|
|
|
|
// 하이브리드 모드 필드
|
|
|
|
|
|
moveType: step.moveType || "status",
|
|
|
|
|
|
statusColumn: step.statusColumn || "",
|
|
|
|
|
|
statusValue: step.statusValue || "",
|
|
|
|
|
|
targetTable: step.targetTable || "",
|
|
|
|
|
|
fieldMappings: step.fieldMappings || {},
|
2025-10-20 17:50:27 +09:00
|
|
|
|
// 외부 연동 필드
|
|
|
|
|
|
integrationType: step.integrationType || "internal",
|
|
|
|
|
|
integrationConfig: step.integrationConfig,
|
2025-10-20 10:55:33 +09:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const [tableList, setTableList] = useState<any[]>([]);
|
|
|
|
|
|
const [loadingTables, setLoadingTables] = useState(true);
|
|
|
|
|
|
const [openTableCombobox, setOpenTableCombobox] = useState(false);
|
|
|
|
|
|
|
2025-10-20 17:50:27 +09:00
|
|
|
|
// 외부 DB 테이블 목록
|
|
|
|
|
|
const [externalTableList, setExternalTableList] = useState<string[]>([]);
|
|
|
|
|
|
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
|
|
|
|
|
|
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID
|
|
|
|
|
|
|
2025-10-20 15:53:00 +09:00
|
|
|
|
// 컬럼 목록 (상태 컬럼 선택용)
|
|
|
|
|
|
const [columns, setColumns] = useState<any[]>([]);
|
|
|
|
|
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
|
|
|
|
|
const [openStatusColumnCombobox, setOpenStatusColumnCombobox] = useState(false);
|
|
|
|
|
|
|
2025-10-20 17:50:27 +09:00
|
|
|
|
// 외부 DB 연결 목록
|
|
|
|
|
|
const [externalConnections, setExternalConnections] = useState<FlowExternalDbConnection[]>([]);
|
|
|
|
|
|
const [loadingConnections, setLoadingConnections] = useState(false);
|
|
|
|
|
|
|
2025-10-20 10:55:33 +09:00
|
|
|
|
// 테이블 목록 조회
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const loadTables = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoadingTables(true);
|
|
|
|
|
|
const response = await tableManagementApi.getTableList();
|
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
|
setTableList(response.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("Failed to load tables:", error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingTables(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
loadTables();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-10-20 17:50:27 +09:00
|
|
|
|
// 외부 DB 연결 목록 조회 (JWT 토큰 사용)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2025-10-20 10:55:33 +09:00
|
|
|
|
useEffect(() => {
|
2025-10-20 17:50:27 +09:00
|
|
|
|
console.log("🔄 Initializing formData from step:", {
|
|
|
|
|
|
id: step.id,
|
|
|
|
|
|
stepName: step.stepName,
|
|
|
|
|
|
statusColumn: step.statusColumn,
|
|
|
|
|
|
statusValue: step.statusValue,
|
2025-10-21 13:19:18 +09:00
|
|
|
|
flowTableName, // 플로우 정의의 테이블명
|
2025-10-20 17:50:27 +09:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const newFormData = {
|
2025-10-20 10:55:33 +09:00
|
|
|
|
stepName: step.stepName,
|
2025-10-21 13:19:18 +09:00
|
|
|
|
tableName: step.tableName || flowTableName || "", // 플로우 테이블명 우선 사용
|
2025-10-20 10:55:33 +09:00
|
|
|
|
conditionJson: step.conditionJson,
|
2025-10-20 15:53:00 +09:00
|
|
|
|
// 하이브리드 모드 필드
|
|
|
|
|
|
moveType: step.moveType || "status",
|
|
|
|
|
|
statusColumn: step.statusColumn || "",
|
|
|
|
|
|
statusValue: step.statusValue || "",
|
|
|
|
|
|
targetTable: step.targetTable || "",
|
|
|
|
|
|
fieldMappings: step.fieldMappings || {},
|
2025-10-20 17:50:27 +09:00
|
|
|
|
// 외부 연동 필드
|
|
|
|
|
|
integrationType: step.integrationType || "internal",
|
|
|
|
|
|
integrationConfig: step.integrationConfig,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
console.log("✅ Setting formData:", newFormData);
|
|
|
|
|
|
setFormData(newFormData);
|
2025-10-21 13:19:18 +09:00
|
|
|
|
}, [step.id, flowTableName]); // flowTableName도 의존성 추가
|
2025-10-20 10:55:33 +09:00
|
|
|
|
|
2025-10-21 13:19:18 +09:00
|
|
|
|
// 테이블 선택 시 컬럼 로드 - 내부/외부 DB 모두 지원
|
2025-10-20 15:53:00 +09:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const loadColumns = async () => {
|
|
|
|
|
|
if (!formData.tableName) {
|
|
|
|
|
|
setColumns([]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoadingColumns(true);
|
2025-10-21 13:19:18 +09:00
|
|
|
|
console.log("🔍 Loading columns for status column selector:", {
|
|
|
|
|
|
tableName: formData.tableName,
|
|
|
|
|
|
flowDbSourceType,
|
|
|
|
|
|
flowDbConnectionId,
|
|
|
|
|
|
});
|
2025-10-20 15:53:00 +09:00
|
|
|
|
|
2025-10-21 13:19:18 +09:00
|
|
|
|
// 외부 DB인 경우
|
|
|
|
|
|
if (flowDbSourceType === "external" && flowDbConnectionId) {
|
|
|
|
|
|
const token = localStorage.getItem("authToken");
|
|
|
|
|
|
if (!token) {
|
|
|
|
|
|
console.warn("토큰이 없습니다. 외부 DB 컬럼 목록을 조회할 수 없습니다.");
|
|
|
|
|
|
setColumns([]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 외부 DB 컬럼 조회 API
|
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
|
`/api/multi-connection/connections/${flowDbConnectionId}/tables/${formData.tableName}/columns`,
|
|
|
|
|
|
{
|
|
|
|
|
|
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 columns API response:", result);
|
|
|
|
|
|
|
|
|
|
|
|
if (result.success && result.data) {
|
|
|
|
|
|
// 컬럼 데이터 형식 통일
|
|
|
|
|
|
const columnList = Array.isArray(result.data)
|
|
|
|
|
|
? result.data.map((col: any) => ({
|
|
|
|
|
|
column_name: col.column_name || col.columnName || col.name,
|
|
|
|
|
|
data_type: col.data_type || col.dataType || col.type,
|
|
|
|
|
|
}))
|
|
|
|
|
|
: [];
|
|
|
|
|
|
console.log("✅ Setting external columns:", columnList);
|
|
|
|
|
|
setColumns(columnList);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.warn("❌ No data in external columns response");
|
|
|
|
|
|
setColumns([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.warn(`외부 DB 컬럼 목록 조회 실패: ${response?.status || "네트워크 오류"}`);
|
|
|
|
|
|
setColumns([]);
|
|
|
|
|
|
}
|
2025-10-20 15:53:00 +09:00
|
|
|
|
} else {
|
2025-10-21 13:19:18 +09:00
|
|
|
|
// 내부 DB인 경우 (기존 로직)
|
|
|
|
|
|
const response = await getTableColumns(formData.tableName);
|
|
|
|
|
|
console.log("📦 Internal columns response:", response);
|
|
|
|
|
|
|
|
|
|
|
|
if (response.success && response.data && response.data.columns) {
|
|
|
|
|
|
console.log("✅ Setting internal columns:", response.data.columns);
|
|
|
|
|
|
setColumns(response.data.columns);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log("❌ No columns in response");
|
|
|
|
|
|
setColumns([]);
|
|
|
|
|
|
}
|
2025-10-20 15:53:00 +09:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("Failed to load columns:", error);
|
|
|
|
|
|
setColumns([]);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingColumns(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
loadColumns();
|
2025-10-21 13:19:18 +09:00
|
|
|
|
}, [formData.tableName, flowDbSourceType, flowDbConnectionId]);
|
2025-10-20 15:53:00 +09:00
|
|
|
|
|
2025-10-20 17:50:27 +09:00
|
|
|
|
// formData의 최신 값을 항상 참조하기 위한 ref
|
|
|
|
|
|
const formDataRef = useRef(formData);
|
|
|
|
|
|
|
|
|
|
|
|
// formData가 변경될 때마다 ref 업데이트
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
formDataRef.current = formData;
|
|
|
|
|
|
}, [formData]);
|
|
|
|
|
|
|
2025-10-20 10:55:33 +09:00
|
|
|
|
// 저장
|
2025-10-20 17:50:27 +09:00
|
|
|
|
const handleSave = useCallback(async () => {
|
|
|
|
|
|
const currentFormData = formDataRef.current;
|
|
|
|
|
|
console.log("🚀 handleSave called, formData:", JSON.stringify(currentFormData, null, 2));
|
2025-10-21 13:19:18 +09:00
|
|
|
|
|
|
|
|
|
|
// 상태 변경 방식일 때 필수 필드 검증
|
|
|
|
|
|
if (currentFormData.moveType === "status") {
|
|
|
|
|
|
if (!currentFormData.statusColumn) {
|
|
|
|
|
|
toast({
|
|
|
|
|
|
title: "입력 오류",
|
|
|
|
|
|
description: "상태 변경 방식을 사용하려면 '상태 컬럼명'을 반드시 지정해야 합니다.",
|
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!currentFormData.statusValue) {
|
|
|
|
|
|
toast({
|
|
|
|
|
|
title: "입력 오류",
|
|
|
|
|
|
description: "상태 변경 방식을 사용하려면 '이 단계의 상태값'을 반드시 지정해야 합니다.",
|
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-20 10:55:33 +09:00
|
|
|
|
try {
|
2025-10-20 17:50:27 +09:00
|
|
|
|
const response = await updateFlowStep(step.id, currentFormData);
|
|
|
|
|
|
console.log("📡 API response:", response);
|
2025-10-20 10:55:33 +09:00
|
|
|
|
if (response.success) {
|
|
|
|
|
|
toast({
|
|
|
|
|
|
title: "저장 완료",
|
|
|
|
|
|
description: "단계가 수정되었습니다.",
|
|
|
|
|
|
});
|
|
|
|
|
|
onUpdate();
|
|
|
|
|
|
onClose();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast({
|
|
|
|
|
|
title: "저장 실패",
|
|
|
|
|
|
description: response.error,
|
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
toast({
|
|
|
|
|
|
title: "오류 발생",
|
|
|
|
|
|
description: error.message,
|
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-10-20 17:50:27 +09:00
|
|
|
|
}, [step.id, onUpdate, onClose, toast]);
|
2025-10-20 10:55:33 +09:00
|
|
|
|
|
|
|
|
|
|
// 삭제
|
|
|
|
|
|
const handleDelete = async () => {
|
|
|
|
|
|
if (!confirm(`"${step.stepName}" 단계를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await deleteFlowStep(step.id);
|
|
|
|
|
|
if (response.success) {
|
|
|
|
|
|
toast({
|
|
|
|
|
|
title: "삭제 완료",
|
|
|
|
|
|
description: "단계가 삭제되었습니다.",
|
|
|
|
|
|
});
|
|
|
|
|
|
onUpdate();
|
|
|
|
|
|
onClose();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast({
|
|
|
|
|
|
title: "삭제 실패",
|
|
|
|
|
|
description: response.error,
|
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
toast({
|
|
|
|
|
|
title: "오류 발생",
|
|
|
|
|
|
description: error.message,
|
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="fixed top-0 right-0 z-50 h-full w-96 overflow-y-auto border-l bg-white shadow-xl">
|
|
|
|
|
|
<div className="space-y-6 p-6">
|
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<h2 className="text-xl font-bold">단계 설정</h2>
|
|
|
|
|
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
|
|
|
|
<X className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 기본 정보 */}
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>기본 정보</CardTitle>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label>단계 이름</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={formData.stepName}
|
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, stepName: e.target.value })}
|
|
|
|
|
|
placeholder="단계 이름 입력"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label>단계 순서</Label>
|
|
|
|
|
|
<Input value={step.stepOrder} disabled />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-21 13:19:18 +09:00
|
|
|
|
{/* ===== 구버전: 단계별 테이블 선택 방식 (주석처리) ===== */}
|
2025-10-20 17:50:27 +09:00
|
|
|
|
{/* DB 소스 선택 */}
|
2025-10-21 13:19:18 +09:00
|
|
|
|
{/* <div>
|
2025-10-20 17:50:27 +09:00
|
|
|
|
<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>
|
2025-10-21 13:19:18 +09:00
|
|
|
|
</div> */}
|
2025-10-20 17:50:27 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 테이블 선택 */}
|
2025-10-21 13:19:18 +09:00
|
|
|
|
{/* <div>
|
2025-10-20 10:55:33 +09:00
|
|
|
|
<Label>조회할 테이블</Label>
|
|
|
|
|
|
<Popover open={openTableCombobox} onOpenChange={setOpenTableCombobox}>
|
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
role="combobox"
|
|
|
|
|
|
aria-expanded={openTableCombobox}
|
|
|
|
|
|
className="w-full justify-between"
|
2025-10-20 17:50:27 +09:00
|
|
|
|
disabled={loadingTables || (selectedDbSource !== "internal" && loadingExternalTables)}
|
2025-10-20 10:55:33 +09:00
|
|
|
|
>
|
|
|
|
|
|
{formData.tableName
|
2025-10-20 17:50:27 +09:00
|
|
|
|
? selectedDbSource === "internal"
|
|
|
|
|
|
? tableList.find((table) => table.tableName === formData.tableName)?.displayName ||
|
|
|
|
|
|
formData.tableName
|
|
|
|
|
|
: formData.tableName
|
|
|
|
|
|
: loadingTables || loadingExternalTables
|
2025-10-20 10:55:33 +09:00
|
|
|
|
? "로딩 중..."
|
|
|
|
|
|
: "테이블 선택"}
|
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</PopoverTrigger>
|
2025-10-20 17:50:27 +09:00
|
|
|
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
2025-10-20 10:55:33 +09:00
|
|
|
|
<Command>
|
|
|
|
|
|
<CommandInput placeholder="테이블 검색..." />
|
|
|
|
|
|
<CommandList>
|
|
|
|
|
|
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
|
|
|
|
|
<CommandGroup>
|
2025-10-20 17:50:27 +09:00
|
|
|
|
{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>
|
|
|
|
|
|
))}
|
2025-10-20 10:55:33 +09:00
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
</CommandList>
|
|
|
|
|
|
</Command>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
2025-10-20 17:50:27 +09:00
|
|
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
|
|
|
|
{selectedDbSource === "internal"
|
|
|
|
|
|
? "이 단계에서 조건을 적용할 테이블을 선택합니다"
|
|
|
|
|
|
: "외부 데이터베이스의 테이블을 선택합니다"}
|
|
|
|
|
|
</p>
|
2025-10-21 13:19:18 +09:00
|
|
|
|
</div> */}
|
|
|
|
|
|
{/* ===== 구버전 끝 ===== */}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ===== 신버전: 플로우에서 선택한 테이블 표시만 ===== */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label>연결된 테이블</Label>
|
|
|
|
|
|
<Input value={formData.tableName || "테이블이 지정되지 않았습니다"} disabled className="bg-gray-50" />
|
|
|
|
|
|
<p className="mt-1 text-xs text-gray-500">플로우 생성 시 선택한 테이블입니다 (수정 불가)</p>
|
2025-10-20 10:55:33 +09:00
|
|
|
|
</div>
|
2025-10-21 13:19:18 +09:00
|
|
|
|
{/* ===== 신버전 끝 ===== */}
|
2025-10-20 10:55:33 +09:00
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 조건 설정 */}
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>조건 설정</CardTitle>
|
|
|
|
|
|
<CardDescription>이 단계에 포함될 데이터의 조건을 설정합니다</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
{!formData.tableName ? (
|
|
|
|
|
|
<div className="py-8 text-center text-gray-500">먼저 테이블을 선택해주세요</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<FlowConditionBuilder
|
|
|
|
|
|
flowId={flowId}
|
|
|
|
|
|
tableName={formData.tableName}
|
2025-10-21 13:19:18 +09:00
|
|
|
|
dbSourceType={flowDbSourceType}
|
|
|
|
|
|
dbConnectionId={flowDbConnectionId}
|
2025-10-20 10:55:33 +09:00
|
|
|
|
condition={formData.conditionJson}
|
|
|
|
|
|
onChange={(condition) => setFormData({ ...formData, conditionJson: condition })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2025-10-20 15:53:00 +09:00
|
|
|
|
{/* 데이터 이동 설정 */}
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>데이터 이동 설정</CardTitle>
|
|
|
|
|
|
<CardDescription>다음 단계로 데이터를 이동할 때의 동작을 설정합니다</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
|
{/* 이동 방식 선택 */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label>이동 방식</Label>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={formData.moveType}
|
|
|
|
|
|
onValueChange={(value: "status" | "table" | "both") => setFormData({ ...formData, moveType: value })}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="status">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="font-medium">상태 변경</div>
|
|
|
|
|
|
<div className="text-xs text-gray-500">같은 테이블 내에서 상태 컬럼만 업데이트</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="table">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="font-medium">테이블 이동</div>
|
|
|
|
|
|
<div className="text-xs text-gray-500">다른 테이블로 데이터 복사</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="both">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="font-medium">하이브리드</div>
|
|
|
|
|
|
<div className="text-xs text-gray-500">상태 변경 + 테이블 이동</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 상태 변경 설정 (status 또는 both일 때) */}
|
|
|
|
|
|
{(formData.moveType === "status" || formData.moveType === "both") && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label>상태 컬럼명</Label>
|
|
|
|
|
|
<Popover open={openStatusColumnCombobox} onOpenChange={setOpenStatusColumnCombobox}>
|
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
role="combobox"
|
|
|
|
|
|
aria-expanded={openStatusColumnCombobox}
|
|
|
|
|
|
className="w-full justify-between"
|
|
|
|
|
|
disabled={!formData.tableName || loadingColumns}
|
|
|
|
|
|
>
|
|
|
|
|
|
{loadingColumns
|
|
|
|
|
|
? "컬럼 로딩 중..."
|
|
|
|
|
|
: formData.statusColumn
|
2025-10-21 14:21:29 +09:00
|
|
|
|
? (() => {
|
|
|
|
|
|
const col = columns.find(
|
|
|
|
|
|
(c) => (c.column_name || c.columnName) === formData.statusColumn,
|
|
|
|
|
|
);
|
|
|
|
|
|
return col ? col.column_name || col.columnName : formData.statusColumn;
|
|
|
|
|
|
})()
|
2025-10-20 15:53:00 +09:00
|
|
|
|
: "상태 컬럼 선택"}
|
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent className="w-full p-0">
|
|
|
|
|
|
<Command>
|
|
|
|
|
|
<CommandInput placeholder="컬럼 검색..." />
|
|
|
|
|
|
<CommandList>
|
|
|
|
|
|
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
|
|
|
|
|
<CommandGroup>
|
2025-10-21 14:21:29 +09:00
|
|
|
|
{columns.map((column, idx) => {
|
|
|
|
|
|
const columnName = column.column_name || column.columnName || "";
|
|
|
|
|
|
const dataType = column.data_type || column.dataType || "";
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={`${columnName}-${idx}`}
|
|
|
|
|
|
value={columnName}
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
setFormData({ ...formData, statusColumn: columnName });
|
|
|
|
|
|
setOpenStatusColumnCombobox(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"mr-2 h-4 w-4",
|
|
|
|
|
|
formData.statusColumn === columnName ? "opacity-100" : "opacity-0",
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div>{columnName}</div>
|
|
|
|
|
|
<div className="text-xs text-gray-500">({dataType})</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2025-10-20 15:53:00 +09:00
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
</CommandList>
|
|
|
|
|
|
</Command>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
<p className="mt-1 text-xs text-gray-500">업데이트할 컬럼의 이름</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label>이 단계의 상태값</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={formData.statusValue}
|
2025-10-20 17:50:27 +09:00
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
|
console.log("💡 statusValue onChange:", newValue);
|
|
|
|
|
|
setFormData({ ...formData, statusValue: newValue });
|
|
|
|
|
|
console.log("✅ Updated formData:", { ...formData, statusValue: newValue });
|
|
|
|
|
|
}}
|
2025-10-20 15:53:00 +09:00
|
|
|
|
placeholder="예: approved"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p className="mt-1 text-xs text-gray-500">이 단계에 있을 때의 상태값</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 테이블 이동 설정 (table 또는 both일 때) */}
|
|
|
|
|
|
{(formData.moveType === "table" || formData.moveType === "both") && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label>타겟 테이블</Label>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={formData.targetTable}
|
|
|
|
|
|
onValueChange={(value) => setFormData({ ...formData, targetTable: value })}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
|
<SelectValue placeholder="테이블 선택" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{tableList.map((table) => (
|
|
|
|
|
|
<SelectItem key={table.tableName} value={table.tableName}>
|
|
|
|
|
|
{table.displayName || table.tableName}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<p className="mt-1 text-xs text-gray-500">다음 단계로 이동 시 데이터가 저장될 테이블</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="rounded-md bg-blue-50 p-3">
|
|
|
|
|
|
<p className="text-sm text-blue-900">
|
|
|
|
|
|
💡 필드 매핑은 향후 구현 예정입니다. 현재는 같은 이름의 컬럼만 자동 매핑됩니다.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2025-10-20 17:50:27 +09:00
|
|
|
|
{/* 외부 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>
|
|
|
|
|
|
|
2025-10-20 10:55:33 +09:00
|
|
|
|
{/* 액션 버튼 */}
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Button className="flex-1" onClick={handleSave}>
|
|
|
|
|
|
<Save className="mr-2 h-4 w-4" />
|
|
|
|
|
|
저장
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button variant="destructive" onClick={handleDelete}>
|
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|