ERP-node/frontend/app/(main)/admin/flow-management/page.tsx

619 lines
25 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 { tableManagementApi } from "@/lib/api/tableManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
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);
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID
const [externalConnections, setExternalConnections] = useState<
Array<{ id: number; connection_name: string; db_type: string }>
>([]);
const [externalTableList, setExternalTableList] = useState<string[]>([]);
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
// 생성 폼 상태
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: 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 token = localStorage.getItem("authToken");
if (!token) {
console.warn("No auth token found");
return;
}
const response = await fetch("/api/external-db-connections/control/active", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response && response.ok) {
const data = await response.json();
if (data.success && data.data) {
// 메인 데이터베이스(현재 시스템) 제외 - connection_name에 "메인" 또는 "현재 시스템"이 포함된 것 필터링
const filtered = data.data.filter(
(conn: { connection_name: string }) =>
!conn.connection_name.includes("메인") && !conn.connection_name.includes("현재 시스템"),
);
setExternalConnections(filtered);
}
}
} catch (error) {
console.error("Failed to load external connections:", error);
setExternalConnections([]);
}
};
loadConnections();
}, []);
// 외부 DB 테이블 목록 로드
useEffect(() => {
if (selectedDbSource === "internal" || !selectedDbSource) {
setExternalTableList([]);
return;
}
const loadExternalTables = async () => {
try {
setLoadingExternalTables(true);
const token = localStorage.getItem("authToken");
const response = await fetch(`/api/multi-connection/connections/${selectedDbSource}/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]);
// 플로우 생성
const handleCreate = async () => {
console.log("🚀 handleCreate called with formData:", formData);
if (!formData.name || !formData.tableName) {
console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName });
toast({
title: "입력 오류",
description: "플로우 이름과 테이블 이름은 필수입니다.",
variant: "destructive",
});
return;
}
try {
// DB 소스 정보 추가
const requestData = {
...formData,
dbSourceType: selectedDbSource === "internal" ? "internal" : "external",
dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource),
};
console.log("✅ Calling createFlowDefinition with:", requestData);
const response = await createFlowDefinition(requestData);
if (response.success && response.data) {
toast({
title: "생성 완료",
description: "플로우가 성공적으로 생성되었습니다.",
});
setIsCreateDialogOpen(false);
setFormData({ name: "", description: "", tableName: "" });
setSelectedDbSource("internal");
loadFlows();
} else {
toast({
title: "생성 실패",
description: 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: 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>
{/* DB 소스 선택 */}
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={selectedDbSource.toString()}
onValueChange={(value) => {
const dbSource = value === "internal" ? "internal" : parseInt(value);
setSelectedDbSource(dbSource);
// DB 소스 변경 시 테이블 선택 초기화
setFormData({ ...formData, tableName: "" });
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="데이터베이스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal"> </SelectItem>
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.connection_name} ({conn.db_type?.toUpperCase()})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
{/* 테이블 선택 */}
<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">
&ldquo;{selectedFlow?.name}&rdquo; ?
<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>
);
}