1104 lines
47 KiB
TypeScript
1104 lines
47 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 플로우 관리 메인 페이지
|
|
* - 플로우 정의 목록
|
|
* - 플로우 생성/수정/삭제
|
|
* - 플로우 편집기로 이동
|
|
*/
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Plus, Edit2, Trash2, Workflow, Table, Calendar, User, Check, ChevronsUpDown } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { getFlowDefinitions, createFlowDefinition, deleteFlowDefinition } from "@/lib/api/flow";
|
|
import { FlowDefinition } from "@/types/flow";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { cn } from "@/lib/utils";
|
|
import { formatErrorMessage } from "@/lib/utils/errorUtils";
|
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
|
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
|
|
|
export default function FlowManagementPage() {
|
|
const router = useRouter();
|
|
const { toast } = useToast();
|
|
|
|
// 상태
|
|
const [flows, setFlows] = useState<FlowDefinition[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
const [selectedFlow, setSelectedFlow] = useState<FlowDefinition | null>(null);
|
|
|
|
// 테이블 목록 관련 상태
|
|
const [tableList, setTableList] = useState<Array<{ tableName: string; displayName?: string; description?: string }>>(
|
|
[],
|
|
);
|
|
const [loadingTables, setLoadingTables] = useState(false);
|
|
const [openTableCombobox, setOpenTableCombobox] = useState(false);
|
|
// 데이터 소스 타입: "internal" (내부 DB), "external_db_숫자" (외부 DB), "restapi_숫자" (REST API)
|
|
const [selectedDbSource, setSelectedDbSource] = useState<string>("internal");
|
|
const [externalConnections, setExternalConnections] = useState<
|
|
Array<{ id: number; connection_name: string; db_type: string }>
|
|
>([]);
|
|
const [externalTableList, setExternalTableList] = useState<string[]>([]);
|
|
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
|
|
|
|
// REST API 연결 관련 상태
|
|
const [restApiConnections, setRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
|
|
const [restApiEndpoint, setRestApiEndpoint] = useState("");
|
|
const [restApiJsonPath, setRestApiJsonPath] = useState("response");
|
|
|
|
// 다중 REST API 선택 상태
|
|
interface RestApiConfig {
|
|
connectionId: number;
|
|
connectionName: string;
|
|
endpoint: string;
|
|
jsonPath: string;
|
|
alias: string; // 컬럼 접두어 (예: "api1_")
|
|
}
|
|
const [selectedRestApis, setSelectedRestApis] = useState<RestApiConfig[]>([]);
|
|
const [isMultiRestApi, setIsMultiRestApi] = useState(false); // 다중 REST API 모드
|
|
|
|
// 다중 외부 DB 선택 상태
|
|
interface ExternalDbConfig {
|
|
connectionId: number;
|
|
connectionName: string;
|
|
dbType: string;
|
|
tableName: string;
|
|
alias: string; // 컬럼 접두어 (예: "db1_")
|
|
}
|
|
const [selectedExternalDbs, setSelectedExternalDbs] = useState<ExternalDbConfig[]>([]);
|
|
const [isMultiExternalDb, setIsMultiExternalDb] = useState(false); // 다중 외부 DB 모드
|
|
const [multiDbTableLists, setMultiDbTableLists] = useState<Record<number, string[]>>({}); // 각 DB별 테이블 목록
|
|
|
|
// 생성 폼 상태
|
|
const [formData, setFormData] = useState({
|
|
name: "",
|
|
description: "",
|
|
tableName: "",
|
|
});
|
|
|
|
// 플로우 목록 조회
|
|
const loadFlows = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await getFlowDefinitions({ isActive: true });
|
|
if (response.success && response.data) {
|
|
setFlows(response.data);
|
|
} else {
|
|
toast({
|
|
title: "조회 실패",
|
|
description: formatErrorMessage(response.error, "플로우 목록을 불러올 수 없습니다."),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
toast({
|
|
title: "오류 발생",
|
|
description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadFlows();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// 테이블 목록 로드 (내부 DB)
|
|
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();
|
|
}, []);
|
|
|
|
// 외부 DB 연결 목록 로드
|
|
useEffect(() => {
|
|
const loadConnections = async () => {
|
|
try {
|
|
const response = await ExternalDbConnectionAPI.getActiveControlConnections();
|
|
|
|
if (response.success && response.data) {
|
|
// 메인 데이터베이스(현재 시스템) 제외 - connection_name에 "메인" 또는 "현재 시스템"이 포함된 것 필터링
|
|
const filtered = response.data.filter(
|
|
(conn) => !conn.connection_name.includes("메인") && !conn.connection_name.includes("현재 시스템"),
|
|
);
|
|
setExternalConnections(filtered);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load external connections:", error);
|
|
setExternalConnections([]);
|
|
}
|
|
};
|
|
loadConnections();
|
|
}, []);
|
|
|
|
// REST API 연결 목록 로드
|
|
useEffect(() => {
|
|
const loadRestApiConnections = async () => {
|
|
try {
|
|
const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" });
|
|
setRestApiConnections(connections);
|
|
} catch (error) {
|
|
console.error("Failed to load REST API connections:", error);
|
|
setRestApiConnections([]);
|
|
}
|
|
};
|
|
loadRestApiConnections();
|
|
}, []);
|
|
|
|
// 외부 DB 테이블 목록 로드
|
|
useEffect(() => {
|
|
// REST API인 경우 테이블 목록 로드 불필요
|
|
if (selectedDbSource === "internal" || !selectedDbSource || selectedDbSource.startsWith("restapi_")) {
|
|
setExternalTableList([]);
|
|
return;
|
|
}
|
|
|
|
// 외부 DB인 경우
|
|
if (selectedDbSource.startsWith("external_db_")) {
|
|
const connectionId = selectedDbSource.replace("external_db_", "");
|
|
|
|
const loadExternalTables = async () => {
|
|
try {
|
|
setLoadingExternalTables(true);
|
|
const token = localStorage.getItem("authToken");
|
|
|
|
const response = await fetch(`/api/multi-connection/connections/${connectionId}/tables`, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
|
|
if (response && response.ok) {
|
|
const data = await response.json();
|
|
if (data.success && data.data) {
|
|
const tables = Array.isArray(data.data) ? data.data : [];
|
|
const tableNames = tables
|
|
.map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) =>
|
|
typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name,
|
|
)
|
|
.filter(Boolean);
|
|
setExternalTableList(tableNames);
|
|
} else {
|
|
setExternalTableList([]);
|
|
}
|
|
} else {
|
|
setExternalTableList([]);
|
|
}
|
|
} catch (error) {
|
|
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
|
setExternalTableList([]);
|
|
} finally {
|
|
setLoadingExternalTables(false);
|
|
}
|
|
};
|
|
|
|
loadExternalTables();
|
|
}
|
|
}, [selectedDbSource]);
|
|
|
|
// 다중 외부 DB 추가
|
|
const addExternalDbConfig = async (connectionId: number) => {
|
|
const connection = externalConnections.find(c => c.id === connectionId);
|
|
if (!connection) return;
|
|
|
|
// 이미 추가된 경우 스킵
|
|
if (selectedExternalDbs.some(db => db.connectionId === connectionId)) {
|
|
toast({
|
|
title: "이미 추가됨",
|
|
description: "해당 외부 DB가 이미 추가되어 있습니다.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 해당 DB의 테이블 목록 로드
|
|
try {
|
|
const data = await ExternalDbConnectionAPI.getTables(connectionId);
|
|
if (data.success && data.data) {
|
|
const tables = Array.isArray(data.data) ? data.data : [];
|
|
const tableNames = tables
|
|
.map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) =>
|
|
typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name,
|
|
)
|
|
.filter(Boolean);
|
|
setMultiDbTableLists(prev => ({ ...prev, [connectionId]: tableNames }));
|
|
}
|
|
} catch (error) {
|
|
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
|
}
|
|
|
|
const newConfig: ExternalDbConfig = {
|
|
connectionId,
|
|
connectionName: connection.connection_name,
|
|
dbType: connection.db_type,
|
|
tableName: "",
|
|
alias: `db${selectedExternalDbs.length + 1}_`, // 자동 별칭 생성
|
|
};
|
|
|
|
setSelectedExternalDbs([...selectedExternalDbs, newConfig]);
|
|
};
|
|
|
|
// 다중 외부 DB 삭제
|
|
const removeExternalDbConfig = (connectionId: number) => {
|
|
setSelectedExternalDbs(selectedExternalDbs.filter(db => db.connectionId !== connectionId));
|
|
};
|
|
|
|
// 다중 외부 DB 설정 업데이트
|
|
const updateExternalDbConfig = (connectionId: number, field: keyof ExternalDbConfig, value: string) => {
|
|
setSelectedExternalDbs(selectedExternalDbs.map(db =>
|
|
db.connectionId === connectionId ? { ...db, [field]: value } : db
|
|
));
|
|
};
|
|
|
|
// 다중 REST API 추가
|
|
const addRestApiConfig = (connectionId: number) => {
|
|
const connection = restApiConnections.find(c => c.id === connectionId);
|
|
if (!connection) return;
|
|
|
|
// 이미 추가된 경우 스킵
|
|
if (selectedRestApis.some(api => api.connectionId === connectionId)) {
|
|
toast({
|
|
title: "이미 추가됨",
|
|
description: "해당 REST API가 이미 추가되어 있습니다.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 연결 테이블의 기본값 사용
|
|
const newConfig: RestApiConfig = {
|
|
connectionId,
|
|
connectionName: connection.connection_name,
|
|
endpoint: connection.endpoint_path || "", // 연결 테이블의 기본 엔드포인트
|
|
jsonPath: "response", // 기본값
|
|
alias: `api${selectedRestApis.length + 1}_`, // 자동 별칭 생성
|
|
};
|
|
|
|
setSelectedRestApis([...selectedRestApis, newConfig]);
|
|
};
|
|
|
|
// 다중 REST API 삭제
|
|
const removeRestApiConfig = (connectionId: number) => {
|
|
setSelectedRestApis(selectedRestApis.filter(api => api.connectionId !== connectionId));
|
|
};
|
|
|
|
// 다중 REST API 설정 업데이트
|
|
const updateRestApiConfig = (connectionId: number, field: keyof RestApiConfig, value: string) => {
|
|
setSelectedRestApis(selectedRestApis.map(api =>
|
|
api.connectionId === connectionId ? { ...api, [field]: value } : api
|
|
));
|
|
};
|
|
|
|
// 플로우 생성
|
|
const handleCreate = async () => {
|
|
console.log("🚀 handleCreate called with formData:", formData);
|
|
|
|
// REST API 또는 다중 선택인 경우 테이블 이름 검증 스킵
|
|
const isRestApi = selectedDbSource.startsWith("restapi_") || isMultiRestApi;
|
|
const isMultiMode = isMultiRestApi || isMultiExternalDb;
|
|
|
|
if (!formData.name || (!isRestApi && !isMultiMode && !formData.tableName)) {
|
|
console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi, isMultiMode });
|
|
toast({
|
|
title: "입력 오류",
|
|
description: (isRestApi || isMultiMode) ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 다중 REST API 모드인 경우 검증
|
|
if (isMultiRestApi) {
|
|
if (selectedRestApis.length === 0) {
|
|
toast({
|
|
title: "입력 오류",
|
|
description: "최소 하나의 REST API를 추가해주세요.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 각 API의 엔드포인트 검증
|
|
const missingEndpoint = selectedRestApis.find(api => !api.endpoint);
|
|
if (missingEndpoint) {
|
|
toast({
|
|
title: "입력 오류",
|
|
description: `${missingEndpoint.connectionName}의 엔드포인트를 입력해주세요.`,
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
} else if (isMultiExternalDb) {
|
|
// 다중 외부 DB 모드인 경우 검증
|
|
if (selectedExternalDbs.length === 0) {
|
|
toast({
|
|
title: "입력 오류",
|
|
description: "최소 하나의 외부 DB를 추가해주세요.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 각 DB의 테이블 선택 검증
|
|
const missingTable = selectedExternalDbs.find(db => !db.tableName);
|
|
if (missingTable) {
|
|
toast({
|
|
title: "입력 오류",
|
|
description: `${missingTable.connectionName}의 테이블을 선택해주세요.`,
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
} else if (isRestApi && !restApiEndpoint) {
|
|
// 단일 REST API인 경우 엔드포인트 검증
|
|
toast({
|
|
title: "입력 오류",
|
|
description: "REST API 엔드포인트는 필수입니다.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 데이터 소스 타입 및 ID 파싱
|
|
let dbSourceType: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db" = "internal";
|
|
let dbConnectionId: number | undefined = undefined;
|
|
let restApiConnectionId: number | undefined = undefined;
|
|
|
|
if (isMultiRestApi) {
|
|
dbSourceType = "multi_restapi";
|
|
} else if (isMultiExternalDb) {
|
|
dbSourceType = "multi_external_db";
|
|
} else if (selectedDbSource === "internal") {
|
|
dbSourceType = "internal";
|
|
} else if (selectedDbSource.startsWith("external_db_")) {
|
|
dbSourceType = "external";
|
|
dbConnectionId = parseInt(selectedDbSource.replace("external_db_", ""));
|
|
} else if (selectedDbSource.startsWith("restapi_")) {
|
|
dbSourceType = "restapi";
|
|
restApiConnectionId = parseInt(selectedDbSource.replace("restapi_", ""));
|
|
}
|
|
|
|
// 요청 데이터 구성
|
|
const requestData: Record<string, unknown> = {
|
|
...formData,
|
|
dbSourceType,
|
|
dbConnectionId,
|
|
};
|
|
|
|
// 다중 REST API인 경우
|
|
if (dbSourceType === "multi_restapi") {
|
|
requestData.restApiConnections = selectedRestApis;
|
|
// 다중 REST API는 첫 번째 API의 ID를 기본으로 사용
|
|
requestData.restApiConnectionId = selectedRestApis[0]?.connectionId;
|
|
requestData.restApiEndpoint = selectedRestApis[0]?.endpoint;
|
|
requestData.restApiJsonPath = selectedRestApis[0]?.jsonPath || "response";
|
|
// 가상 테이블명: 모든 연결 ID를 조합
|
|
requestData.tableName = `_multi_restapi_${selectedRestApis.map(a => a.connectionId).join("_")}`;
|
|
} else if (dbSourceType === "multi_external_db") {
|
|
// 다중 외부 DB인 경우
|
|
requestData.externalDbConnections = selectedExternalDbs;
|
|
// 첫 번째 DB의 ID를 기본으로 사용
|
|
requestData.dbConnectionId = selectedExternalDbs[0]?.connectionId;
|
|
// 가상 테이블명: 모든 연결 ID와 테이블명 조합
|
|
requestData.tableName = `_multi_external_db_${selectedExternalDbs.map(db => `${db.connectionId}_${db.tableName}`).join("_")}`;
|
|
} else if (dbSourceType === "restapi") {
|
|
// 단일 REST API인 경우
|
|
requestData.restApiConnectionId = restApiConnectionId;
|
|
requestData.restApiEndpoint = restApiEndpoint;
|
|
requestData.restApiJsonPath = restApiJsonPath || "response";
|
|
// REST API는 가상 테이블명 사용
|
|
requestData.tableName = `_restapi_${restApiConnectionId}`;
|
|
}
|
|
|
|
console.log("✅ Calling createFlowDefinition with:", requestData);
|
|
const response = await createFlowDefinition(requestData as Parameters<typeof createFlowDefinition>[0]);
|
|
if (response.success && response.data) {
|
|
toast({
|
|
title: "생성 완료",
|
|
description: "플로우가 성공적으로 생성되었습니다.",
|
|
});
|
|
setIsCreateDialogOpen(false);
|
|
setFormData({ name: "", description: "", tableName: "" });
|
|
setSelectedDbSource("internal");
|
|
setRestApiEndpoint("");
|
|
setRestApiJsonPath("response");
|
|
setSelectedRestApis([]);
|
|
setSelectedExternalDbs([]);
|
|
setIsMultiRestApi(false);
|
|
setIsMultiExternalDb(false);
|
|
loadFlows();
|
|
} else {
|
|
toast({
|
|
title: "생성 실패",
|
|
description: formatErrorMessage(response.error || response.message, "플로우 생성 중 오류가 발생했습니다."),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
toast({
|
|
title: "오류 발생",
|
|
description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
// 플로우 삭제
|
|
const handleDelete = async () => {
|
|
if (!selectedFlow) return;
|
|
|
|
try {
|
|
const response = await deleteFlowDefinition(selectedFlow.id);
|
|
if (response.success) {
|
|
toast({
|
|
title: "삭제 완료",
|
|
description: "플로우가 삭제되었습니다.",
|
|
});
|
|
setIsDeleteDialogOpen(false);
|
|
setSelectedFlow(null);
|
|
loadFlows();
|
|
} else {
|
|
toast({
|
|
title: "삭제 실패",
|
|
description: formatErrorMessage(response.error, "플로우 삭제 중 오류가 발생했습니다."),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
toast({
|
|
title: "오류 발생",
|
|
description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
// 플로우 편집기로 이동
|
|
const handleEdit = (flowId: number) => {
|
|
router.push(`/admin/flow-management/${flowId}`);
|
|
};
|
|
|
|
return (
|
|
<div className="bg-background flex min-h-screen flex-col">
|
|
<div className="space-y-6 p-4 sm:p-6">
|
|
{/* 페이지 헤더 */}
|
|
<div className="space-y-2 border-b pb-4">
|
|
<h1 className="text-3xl font-bold tracking-tight">플로우 관리</h1>
|
|
<p className="text-muted-foreground text-sm">업무 프로세스 플로우를 생성하고 관리합니다</p>
|
|
</div>
|
|
|
|
{/* 액션 버튼 영역 */}
|
|
<div className="flex items-center justify-end">
|
|
<Button onClick={() => setIsCreateDialogOpen(true)} className="h-10 gap-2 text-sm font-medium">
|
|
<Plus className="h-4 w-4" />새 플로우 생성
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 플로우 카드 목록 */}
|
|
{loading ? (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{Array.from({ length: 6 }).map((_, index) => (
|
|
<div key={index} className="bg-card rounded-lg border p-6 shadow-sm">
|
|
<div className="mb-4 space-y-2">
|
|
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
|
<div className="bg-muted h-4 w-full animate-pulse rounded"></div>
|
|
<div className="bg-muted h-4 w-3/4 animate-pulse rounded"></div>
|
|
</div>
|
|
<div className="space-y-2 border-t pt-4">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<div key={i} className="flex items-center gap-2">
|
|
<div className="bg-muted h-4 w-4 animate-pulse rounded"></div>
|
|
<div className="bg-muted h-4 flex-1 animate-pulse rounded"></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-4 flex gap-2 border-t pt-4">
|
|
<div className="bg-muted h-9 flex-1 animate-pulse rounded"></div>
|
|
<div className="bg-muted h-9 w-9 animate-pulse rounded"></div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : flows.length === 0 ? (
|
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
|
<div className="flex flex-col items-center gap-2 text-center">
|
|
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-full">
|
|
<Workflow className="text-muted-foreground h-8 w-8" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold">생성된 플로우가 없습니다</h3>
|
|
<p className="text-muted-foreground max-w-sm text-sm">
|
|
새 플로우를 생성하여 업무 프로세스를 관리해보세요.
|
|
</p>
|
|
<Button onClick={() => setIsCreateDialogOpen(true)} className="mt-4 h-10 gap-2 text-sm font-medium">
|
|
<Plus className="h-4 w-4" />첫 플로우 만들기
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{flows.map((flow) => (
|
|
<div
|
|
key={flow.id}
|
|
className="bg-card hover:bg-muted/50 cursor-pointer rounded-lg border p-6 shadow-sm transition-colors"
|
|
onClick={() => handleEdit(flow.id)}
|
|
>
|
|
{/* 헤더 */}
|
|
<div className="mb-4 flex items-start justify-between">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="truncate text-base font-semibold">{flow.name}</h3>
|
|
{flow.isActive && (
|
|
<Badge className="shrink-0 bg-emerald-500 text-white hover:bg-emerald-600">활성</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">{flow.description || "설명 없음"}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 정보 */}
|
|
<div className="space-y-2 border-t pt-4">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Table className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
<span className="text-muted-foreground truncate">{flow.tableName}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<User className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
<span className="text-muted-foreground truncate">생성자: {flow.createdBy}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Calendar className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
<span className="text-muted-foreground">
|
|
{new Date(flow.updatedAt).toLocaleDateString("ko-KR")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 액션 */}
|
|
<div className="mt-4 flex gap-2 border-t pt-4">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9 flex-1 gap-2 text-sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleEdit(flow.id);
|
|
}}
|
|
>
|
|
<Edit2 className="h-4 w-4" />
|
|
편집
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9 w-9 p-0"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelectedFlow(flow);
|
|
setIsDeleteDialogOpen(true);
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 생성 다이얼로그 */}
|
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">새 플로우 생성</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
새로운 업무 프로세스 플로우를 생성합니다
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<div>
|
|
<Label htmlFor="name" className="text-xs sm:text-sm">
|
|
플로우 이름 *
|
|
</Label>
|
|
<Input
|
|
id="name"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
placeholder="예: 제품 수명주기 관리"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* 데이터 소스 선택 */}
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">데이터 소스</Label>
|
|
<Select
|
|
value={isMultiRestApi ? "multi_restapi" : isMultiExternalDb ? "multi_external_db" : selectedDbSource}
|
|
onValueChange={(value) => {
|
|
if (value === "multi_restapi") {
|
|
setIsMultiRestApi(true);
|
|
setIsMultiExternalDb(false);
|
|
setSelectedDbSource("internal");
|
|
} else if (value === "multi_external_db") {
|
|
setIsMultiExternalDb(true);
|
|
setIsMultiRestApi(false);
|
|
setSelectedDbSource("internal");
|
|
} else {
|
|
setIsMultiRestApi(false);
|
|
setIsMultiExternalDb(false);
|
|
setSelectedDbSource(value);
|
|
}
|
|
// 소스 변경 시 초기화
|
|
setFormData({ ...formData, tableName: "" });
|
|
setRestApiEndpoint("");
|
|
setRestApiJsonPath("response");
|
|
setSelectedRestApis([]);
|
|
setSelectedExternalDbs([]);
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue placeholder="데이터 소스 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{/* 내부 DB */}
|
|
<SelectItem value="internal">내부 데이터베이스</SelectItem>
|
|
|
|
{/* 외부 DB 연결 */}
|
|
{externalConnections.length > 0 && (
|
|
<>
|
|
<SelectItem value="__divider_db__" disabled className="text-muted-foreground text-xs">
|
|
-- 외부 데이터베이스 --
|
|
</SelectItem>
|
|
{externalConnections.map((conn) => (
|
|
<SelectItem key={`db_${conn.id}`} value={`external_db_${conn.id}`}>
|
|
{conn.connection_name} ({conn.db_type?.toUpperCase()})
|
|
</SelectItem>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{/* REST API 연결 */}
|
|
{restApiConnections.length > 0 && (
|
|
<>
|
|
<SelectItem value="__divider_api__" disabled className="text-muted-foreground text-xs">
|
|
-- REST API --
|
|
</SelectItem>
|
|
{restApiConnections.map((conn) => (
|
|
<SelectItem key={`api_${conn.id}`} value={`restapi_${conn.id}`}>
|
|
{conn.connection_name} (REST API)
|
|
</SelectItem>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{/* 다중 연결 옵션 */}
|
|
{(externalConnections.length > 0 || restApiConnections.length > 0) && (
|
|
<>
|
|
<SelectItem value="__divider_multi__" disabled className="text-muted-foreground text-xs">
|
|
-- 다중 연결 (데이터 병합) --
|
|
</SelectItem>
|
|
{externalConnections.length > 0 && (
|
|
<SelectItem value="multi_external_db">
|
|
다중 외부 DB (데이터 병합)
|
|
</SelectItem>
|
|
)}
|
|
{restApiConnections.length > 0 && (
|
|
<SelectItem value="multi_restapi">
|
|
다중 REST API (데이터 병합)
|
|
</SelectItem>
|
|
)}
|
|
</>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
|
플로우에서 사용할 데이터 소스를 선택합니다
|
|
</p>
|
|
</div>
|
|
|
|
{/* 다중 REST API 선택 UI */}
|
|
{isMultiRestApi && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs sm:text-sm">REST API 연결 목록</Label>
|
|
<Select
|
|
value=""
|
|
onValueChange={(value) => {
|
|
if (value) {
|
|
addRestApiConfig(parseInt(value));
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 w-[180px] text-xs sm:h-9 sm:text-sm">
|
|
<SelectValue placeholder="API 추가..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{restApiConnections
|
|
.filter(conn => !selectedRestApis.some(api => api.connectionId === conn.id))
|
|
.map((conn) => (
|
|
<SelectItem key={conn.id} value={String(conn.id)}>
|
|
{conn.connection_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{selectedRestApis.length === 0 ? (
|
|
<div className="rounded-md border border-dashed p-4 text-center">
|
|
<p className="text-muted-foreground text-xs sm:text-sm">
|
|
위에서 REST API를 추가해주세요
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{selectedRestApis.map((api) => (
|
|
<div key={api.connectionId} className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">{api.connectionName}</span>
|
|
<span className="text-muted-foreground text-xs">
|
|
({api.endpoint || "기본 엔드포인트"})
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0"
|
|
onClick={() => removeRestApiConfig(api.connectionId)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p className="text-muted-foreground text-[10px] sm:text-xs">
|
|
선택한 REST API들의 데이터가 자동으로 병합됩니다.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 다중 외부 DB 선택 UI */}
|
|
{isMultiExternalDb && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs sm:text-sm">외부 DB 연결 목록</Label>
|
|
<Select
|
|
value=""
|
|
onValueChange={(value) => {
|
|
if (value) {
|
|
addExternalDbConfig(parseInt(value));
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 w-[180px] text-xs sm:h-9 sm:text-sm">
|
|
<SelectValue placeholder="DB 추가..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{externalConnections
|
|
.filter(conn => !selectedExternalDbs.some(db => db.connectionId === conn.id))
|
|
.map((conn) => (
|
|
<SelectItem key={conn.id} value={String(conn.id)}>
|
|
{conn.connection_name} ({conn.db_type?.toUpperCase()})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{selectedExternalDbs.length === 0 ? (
|
|
<div className="rounded-md border border-dashed p-4 text-center">
|
|
<p className="text-muted-foreground text-xs sm:text-sm">
|
|
위에서 외부 DB를 추가해주세요
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{selectedExternalDbs.map((db) => (
|
|
<div key={db.connectionId} className="rounded-md border p-3 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">
|
|
{db.connectionName} ({db.dbType?.toUpperCase()})
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0"
|
|
onClick={() => removeExternalDbConfig(db.connectionId)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="text-[10px]">테이블</Label>
|
|
<Select
|
|
value={db.tableName}
|
|
onValueChange={(value) => updateExternalDbConfig(db.connectionId, "tableName", value)}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue placeholder="테이블 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(multiDbTableLists[db.connectionId] || []).map((table) => (
|
|
<SelectItem key={table} value={table}>
|
|
{table}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[10px]">별칭 (접두어)</Label>
|
|
<Input
|
|
value={db.alias}
|
|
onChange={(e) => updateExternalDbConfig(db.connectionId, "alias", e.target.value)}
|
|
placeholder="db1_"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p className="text-muted-foreground text-[10px] sm:text-xs">
|
|
선택한 외부 DB들의 데이터가 자동으로 병합됩니다. 각 DB별 테이블을 선택해주세요.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 단일 REST API인 경우 엔드포인트 설정 */}
|
|
{!isMultiRestApi && selectedDbSource.startsWith("restapi_") && (
|
|
<>
|
|
<div>
|
|
<Label htmlFor="restApiEndpoint" className="text-xs sm:text-sm">
|
|
API 엔드포인트 *
|
|
</Label>
|
|
<Input
|
|
id="restApiEndpoint"
|
|
value={restApiEndpoint}
|
|
onChange={(e) => setRestApiEndpoint(e.target.value)}
|
|
placeholder="예: /api/data/list"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
|
데이터를 조회할 API 엔드포인트 경로입니다
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="restApiJsonPath" className="text-xs sm:text-sm">
|
|
JSON 경로
|
|
</Label>
|
|
<Input
|
|
id="restApiJsonPath"
|
|
value={restApiJsonPath}
|
|
onChange={(e) => setRestApiJsonPath(e.target.value)}
|
|
placeholder="예: data 또는 result.items"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
|
응답 JSON에서 데이터 배열의 경로입니다 (기본: data)
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* 테이블 선택 (내부 DB 또는 단일 외부 DB - 다중 선택 모드가 아닌 경우만) */}
|
|
{!isMultiRestApi && !isMultiExternalDb && !selectedDbSource.startsWith("restapi_") && (
|
|
<div>
|
|
<Label htmlFor="tableName" className="text-xs sm:text-sm">
|
|
연결 테이블 *
|
|
</Label>
|
|
<Popover open={openTableCombobox} onOpenChange={setOpenTableCombobox}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={openTableCombobox}
|
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
|
disabled={loadingTables || (selectedDbSource !== "internal" && loadingExternalTables)}
|
|
>
|
|
{formData.tableName
|
|
? 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="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
|
<Command>
|
|
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
|
<CommandList>
|
|
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
|
<CommandGroup>
|
|
{selectedDbSource === "internal"
|
|
? // 내부 DB 테이블 목록
|
|
tableList.map((table) => (
|
|
<CommandItem
|
|
key={table.tableName}
|
|
value={table.tableName}
|
|
onSelect={(currentValue) => {
|
|
console.log("📝 Internal table selected:", {
|
|
tableName: table.tableName,
|
|
currentValue,
|
|
});
|
|
setFormData({ ...formData, tableName: currentValue });
|
|
setOpenTableCombobox(false);
|
|
}}
|
|
className="text-xs sm:text-sm"
|
|
>
|
|
<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-[10px] 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);
|
|
}}
|
|
className="text-xs sm:text-sm"
|
|
>
|
|
<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="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
|
플로우의 모든 단계에서 사용할 기본 테이블입니다 (단계마다 상태 컬럼만 지정합니다)
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<Label htmlFor="description" className="text-xs sm:text-sm">
|
|
설명
|
|
</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
placeholder="플로우에 대한 설명을 입력하세요"
|
|
rows={3}
|
|
className="text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsCreateDialogOpen(false)}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleCreate} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
|
생성
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 삭제 확인 다이얼로그 */}
|
|
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">플로우 삭제</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
정말로 “{selectedFlow?.name}” 플로우를 삭제하시겠습니까?
|
|
<br />이 작업은 되돌릴 수 없습니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setIsDeleteDialogOpen(false);
|
|
setSelectedFlow(null);
|
|
}}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleDelete}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
삭제
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
{/* Scroll to Top 버튼 */}
|
|
<ScrollToTop />
|
|
</div>
|
|
);
|
|
}
|