"use client"; import { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Plus, Search, Network, RefreshCw, Pencil, Copy, Trash2, LayoutGrid, List, Loader2, } from "lucide-react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; import { apiClient } from "@/lib/api/client"; import { getNodePaletteItem } from "@/components/dataflow/node-editor/sidebar/nodePaletteConfig"; interface TopologyNode { id: string; type: string; x: number; y: number; } interface FlowSummary { nodeCount: number; edgeCount: number; nodeTypes: Record; topology: { nodes: TopologyNode[]; edges: [string, string][]; } | null; } interface NodeFlow { flowId: number; flowName: string; flowDescription: string; createdAt: string; updatedAt: string; summary: FlowSummary; } interface DataFlowListProps { onLoadFlow: (flowId: number | null) => void; } const CATEGORY_COLORS: Record = { source: { text: "text-teal-400", bg: "bg-teal-500/10", border: "border-teal-500/20" }, transform: { text: "text-violet-400", bg: "bg-violet-500/10", border: "border-violet-500/20" }, action: { text: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" }, external: { text: "text-pink-400", bg: "bg-pink-500/10", border: "border-pink-500/20" }, utility: { text: "text-zinc-400", bg: "bg-zinc-500/10", border: "border-zinc-500/20" }, }; function getNodeCategoryColor(nodeType: string) { const item = getNodePaletteItem(nodeType); const cat = item?.category || "utility"; return CATEGORY_COLORS[cat] || CATEGORY_COLORS.utility; } function getNodeLabel(nodeType: string) { const item = getNodePaletteItem(nodeType); return item?.label || nodeType; } function getNodeColor(nodeType: string): string { const item = getNodePaletteItem(nodeType); return item?.color || "#6B7280"; } function relativeTime(dateStr: string): string { const now = Date.now(); const d = new Date(dateStr).getTime(); const diff = now - d; const min = Math.floor(diff / 60000); if (min < 1) return "방금 전"; if (min < 60) return `${min}분 전`; const h = Math.floor(min / 60); if (h < 24) return `${h}시간 전`; const day = Math.floor(h / 24); if (day < 30) return `${day}일 전`; const month = Math.floor(day / 30); return `${month}개월 전`; } function MiniTopology({ topology }: { topology: FlowSummary["topology"] }) { if (!topology || topology.nodes.length === 0) { return (
빈 캔버스
); } const W = 340; const H = 88; const padX = 40; const padY = 18; const nodeMap = new Map(topology.nodes.map((n) => [n.id, n])); return ( {topology.edges.map(([src, tgt], i) => { const s = nodeMap.get(src); const t = nodeMap.get(tgt); if (!s || !t) return null; const sx = padX + s.x * (W - padX * 2); const sy = padY + s.y * (H - padY * 2); const tx = padX + t.x * (W - padX * 2); const ty = padY + t.y * (H - padY * 2); const mx = (sx + tx) / 2; const my = (sy + ty) / 2 - 8; return ( ); })} {topology.nodes.map((n) => { const cx = padX + n.x * (W - padX * 2); const cy = padY + n.y * (H - padY * 2); const color = getNodeColor(n.type); return ( ); })} ); } function FlowCard({ flow, onOpen, onCopy, onDelete, }: { flow: NodeFlow; onOpen: () => void; onCopy: () => void; onDelete: () => void; }) { const chips = useMemo(() => { const entries = Object.entries(flow.summary?.nodeTypes || {}); return entries.slice(0, 4).map(([type, count]) => { const colors = getNodeCategoryColor(type); const label = getNodeLabel(type); return { type, count, label, colors }; }); }, [flow.summary?.nodeTypes]); return (
{/* 미니 토폴로지 */}
{/* 카드 바디 */}

{flow.flowName}

{flow.flowDescription || "설명이 아직 없어요"}

{/* 노드 타입 칩 */} {chips.length > 0 && (
{chips.map(({ type, count, label, colors }) => ( {label} {count} ))}
)}
{/* 카드 푸터 */}
수정 {relativeTime(flow.updatedAt)}
); } export default function DataFlowList({ onLoadFlow }: DataFlowListProps) { const [flows, setFlows] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [showDeleteModal, setShowDeleteModal] = useState(false); const [selectedFlow, setSelectedFlow] = useState(null); const loadFlows = useCallback(async () => { try { setLoading(true); const response = await apiClient.get("/dataflow/node-flows"); if (response.data.success) { setFlows(response.data.data); } else { throw new Error(response.data.message || "플로우 목록 조회 실패"); } } catch (error) { console.error("플로우 목록 조회 실패", error); showErrorToast("플로우 목록을 불러오는 데 실패했어요", error, { guidance: "네트워크 연결을 확인해 주세요.", }); } finally { setLoading(false); } }, []); useEffect(() => { loadFlows(); }, [loadFlows]); const handleDelete = (flow: NodeFlow) => { setSelectedFlow(flow); setShowDeleteModal(true); }; const handleCopy = async (flow: NodeFlow) => { try { setLoading(true); const response = await apiClient.get(`/dataflow/node-flows/${flow.flowId}`); if (!response.data.success) throw new Error(response.data.message || "플로우 조회 실패"); const copyResponse = await apiClient.post("/dataflow/node-flows", { flowName: `${flow.flowName} (복사본)`, flowDescription: flow.flowDescription, flowData: response.data.data.flowData, }); if (copyResponse.data.success) { toast.success("플로우를 복사했어요"); await loadFlows(); } else { throw new Error(copyResponse.data.message || "플로우 복사 실패"); } } catch (error) { console.error("플로우 복사 실패:", error); showErrorToast("플로우 복사에 실패했어요", error, { guidance: "잠시 후 다시 시도해 주세요.", }); } finally { setLoading(false); } }; const handleConfirmDelete = async () => { if (!selectedFlow) return; try { setLoading(true); const response = await apiClient.delete(`/dataflow/node-flows/${selectedFlow.flowId}`); if (response.data.success) { toast.success(`"${selectedFlow.flowName}" 플로우를 삭제했어요`); await loadFlows(); } else { throw new Error(response.data.message || "플로우 삭제 실패"); } } catch (error) { console.error("플로우 삭제 실패:", error); showErrorToast("플로우 삭제에 실패했어요", error, { guidance: "잠시 후 다시 시도해 주세요.", }); } finally { setLoading(false); setShowDeleteModal(false); setSelectedFlow(null); } }; const filteredFlows = useMemo( () => flows.filter( (f) => f.flowName.toLowerCase().includes(searchTerm.toLowerCase()) || (f.flowDescription || "").toLowerCase().includes(searchTerm.toLowerCase()), ), [flows, searchTerm], ); const stats = useMemo(() => { let totalNodes = 0; let totalEdges = 0; flows.forEach((f) => { totalNodes += f.summary?.nodeCount || 0; totalEdges += f.summary?.edgeCount || 0; }); return { total: flows.length, totalNodes, totalEdges }; }, [flows]); if (loading && flows.length === 0) { return (
); } return (
{/* 헤더 */}

제어 관리

노드 기반 데이터 플로우를 시각적으로 설계하고 관리해요

{/* 통계 스트립 */}
전체{" "} {stats.total}
총 노드{" "} {stats.totalNodes}
총 연결{" "} {stats.totalEdges}
{/* 툴바 */}
setSearchTerm(e.target.value)} className="h-10 border-zinc-700 bg-zinc-900 pl-10 text-sm text-zinc-200 placeholder:text-zinc-600 focus-visible:ring-violet-500/40" />
{/* 컨텐츠 */} {filteredFlows.length === 0 ? (

{searchTerm ? "검색 결과가 없어요" : "아직 플로우가 없어요"}

{searchTerm ? `"${searchTerm}"에 해당하는 플로우를 찾지 못했어요. 다른 키워드로 검색해 보세요.` : "노드를 연결해서 데이터 처리 파이프라인을 만들어 보세요. 코드 없이 드래그 앤 드롭만으로 설계할 수 있어요."}

{!searchTerm && ( )}
) : viewMode === "grid" ? (
{filteredFlows.map((flow) => ( onLoadFlow(flow.flowId)} onCopy={() => handleCopy(flow)} onDelete={() => handleDelete(flow)} /> ))} {/* 새 플로우 만들기 카드 */}
onLoadFlow(null)} >
새 플로우 만들기 빈 캔버스에서 시작해요
) : (
{filteredFlows.map((flow) => (
onLoadFlow(flow.flowId)} >

{flow.flowName}

{flow.flowDescription || "설명이 아직 없어요"}

{Object.entries(flow.summary?.nodeTypes || {}) .slice(0, 3) .map(([type, count]) => { const colors = getNodeCategoryColor(type); return ( {getNodeLabel(type)} {count} ); })}
{relativeTime(flow.updatedAt)}
e.stopPropagation()}>
))}
)} {/* 삭제 확인 모달 */} 플로우를 삭제할까요? “{selectedFlow?.flowName}” 플로우가 완전히 삭제돼요.
이 작업은 되돌릴 수 없으며, 모든 노드와 연결 정보가 함께 삭제돼요.
); }