2025-09-09 11:35:05 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2026-03-19 15:07:07 +09:00
|
|
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
2025-09-09 11:35:05 +09:00
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
2025-09-09 13:10:03 +09:00
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from "@/components/ui/dialog";
|
2026-03-19 15:07:07 +09:00
|
|
|
import {
|
|
|
|
|
Plus,
|
|
|
|
|
Search,
|
|
|
|
|
Network,
|
|
|
|
|
RefreshCw,
|
|
|
|
|
Pencil,
|
|
|
|
|
Copy,
|
|
|
|
|
Trash2,
|
|
|
|
|
LayoutGrid,
|
|
|
|
|
List,
|
|
|
|
|
Loader2,
|
|
|
|
|
} from "lucide-react";
|
2025-09-09 11:35:05 +09:00
|
|
|
import { toast } from "sonner";
|
2026-03-03 16:04:11 +09:00
|
|
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
2025-10-16 15:05:24 +09:00
|
|
|
import { apiClient } from "@/lib/api/client";
|
2026-03-19 15:07:07 +09:00
|
|
|
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<string, number>;
|
|
|
|
|
topology: {
|
|
|
|
|
nodes: TopologyNode[];
|
|
|
|
|
edges: [string, string][];
|
|
|
|
|
} | null;
|
|
|
|
|
}
|
2025-10-16 15:05:24 +09:00
|
|
|
|
|
|
|
|
interface NodeFlow {
|
|
|
|
|
flowId: number;
|
|
|
|
|
flowName: string;
|
|
|
|
|
flowDescription: string;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
updatedAt: string;
|
2026-03-19 15:07:07 +09:00
|
|
|
summary: FlowSummary;
|
2025-10-16 15:05:24 +09:00
|
|
|
}
|
2025-09-09 11:35:05 +09:00
|
|
|
|
|
|
|
|
interface DataFlowListProps {
|
2025-10-16 15:05:24 +09:00
|
|
|
onLoadFlow: (flowId: number | null) => void;
|
2025-09-09 11:35:05 +09:00
|
|
|
}
|
|
|
|
|
|
2026-03-19 15:07:07 +09:00
|
|
|
const CATEGORY_COLORS: Record<string, { text: string; bg: string; border: string }> = {
|
|
|
|
|
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 (
|
|
|
|
|
<div className="flex h-full items-center justify-center">
|
|
|
|
|
<span className="font-mono text-[10px] text-zinc-600">빈 캔버스</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const W = 340;
|
|
|
|
|
const H = 88;
|
|
|
|
|
const padX = 40;
|
|
|
|
|
const padY = 18;
|
|
|
|
|
|
|
|
|
|
const nodeMap = new Map(topology.nodes.map((n) => [n.id, n]));
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<svg viewBox={`0 0 ${W} ${H}`} fill="none" className="h-full w-full">
|
|
|
|
|
{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 (
|
|
|
|
|
<path
|
|
|
|
|
key={`e-${i}`}
|
|
|
|
|
d={`M${sx} ${sy}Q${mx} ${my} ${tx} ${ty}`}
|
|
|
|
|
stroke="rgba(108,92,231,0.25)"
|
|
|
|
|
strokeWidth="1.5"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
{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 (
|
|
|
|
|
<g key={n.id}>
|
|
|
|
|
<circle cx={cx} cy={cy} r="5" fill={`${color}20`} stroke={color} strokeWidth="1.5" />
|
|
|
|
|
<circle cx={cx} cy={cy} r="2" fill={color} />
|
|
|
|
|
</g>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<div
|
|
|
|
|
className="group relative cursor-pointer overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/80 transition-all duration-200 hover:-translate-y-0.5 hover:border-violet-500/50 hover:shadow-lg hover:shadow-violet-500/5"
|
|
|
|
|
onClick={onOpen}
|
|
|
|
|
>
|
|
|
|
|
{/* 미니 토폴로지 */}
|
|
|
|
|
<div className="relative h-[88px] overflow-hidden border-b border-zinc-800/60 bg-gradient-to-b from-violet-500/[0.03] to-transparent">
|
|
|
|
|
<MiniTopology topology={flow.summary?.topology} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 카드 바디 */}
|
|
|
|
|
<div className="px-4 pb-3 pt-3.5">
|
|
|
|
|
<h3 className="mb-1 truncate text-sm font-semibold tracking-tight text-zinc-100">
|
|
|
|
|
{flow.flowName}
|
|
|
|
|
</h3>
|
|
|
|
|
<p className="mb-3 line-clamp-2 min-h-[2.5rem] text-[11px] leading-relaxed text-zinc-500">
|
|
|
|
|
{flow.flowDescription || "설명이 아직 없어요"}
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
{/* 노드 타입 칩 */}
|
|
|
|
|
{chips.length > 0 && (
|
|
|
|
|
<div className="mb-3 flex flex-wrap gap-1.5">
|
|
|
|
|
{chips.map(({ type, count, label, colors }) => (
|
|
|
|
|
<span
|
|
|
|
|
key={type}
|
|
|
|
|
className={`inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${colors.text} ${colors.bg} ${colors.border}`}
|
|
|
|
|
>
|
|
|
|
|
{label} {count}
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 카드 푸터 */}
|
|
|
|
|
<div className="flex items-center justify-between border-t border-zinc-800/40 px-4 py-2.5">
|
|
|
|
|
<span className="font-mono text-[11px] text-zinc-600">
|
|
|
|
|
수정 {relativeTime(flow.updatedAt)}
|
|
|
|
|
</span>
|
|
|
|
|
<div className="flex gap-0.5">
|
|
|
|
|
<button
|
|
|
|
|
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-violet-500/10 hover:text-violet-400"
|
|
|
|
|
title="편집"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onOpen();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-violet-500/10 hover:text-violet-400"
|
|
|
|
|
title="복사"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onCopy();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Copy className="h-3.5 w-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-pink-500/10 hover:text-pink-400"
|
|
|
|
|
title="삭제"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onDelete();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 15:05:24 +09:00
|
|
|
export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
|
|
|
|
const [flows, setFlows] = useState<NodeFlow[]>([]);
|
2025-09-09 11:35:05 +09:00
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [searchTerm, setSearchTerm] = useState("");
|
2026-03-19 15:07:07 +09:00
|
|
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
2025-09-09 13:10:03 +09:00
|
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
2025-10-16 15:05:24 +09:00
|
|
|
const [selectedFlow, setSelectedFlow] = useState<NodeFlow | null>(null);
|
2025-09-09 11:35:05 +09:00
|
|
|
|
2025-10-16 15:05:24 +09:00
|
|
|
const loadFlows = useCallback(async () => {
|
2025-09-09 13:10:03 +09:00
|
|
|
try {
|
|
|
|
|
setLoading(true);
|
2025-10-16 15:05:24 +09:00
|
|
|
const response = await apiClient.get("/dataflow/node-flows");
|
|
|
|
|
if (response.data.success) {
|
|
|
|
|
setFlows(response.data.data);
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(response.data.message || "플로우 목록 조회 실패");
|
|
|
|
|
}
|
2025-09-09 13:10:03 +09:00
|
|
|
} catch (error) {
|
2025-10-16 15:05:24 +09:00
|
|
|
console.error("플로우 목록 조회 실패", error);
|
2026-03-19 15:07:07 +09:00
|
|
|
showErrorToast("플로우 목록을 불러오는 데 실패했어요", error, {
|
|
|
|
|
guidance: "네트워크 연결을 확인해 주세요.",
|
|
|
|
|
});
|
2025-09-09 13:10:03 +09:00
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
2025-10-16 15:05:24 +09:00
|
|
|
}, []);
|
2025-09-09 11:35:05 +09:00
|
|
|
|
2025-09-09 13:10:03 +09:00
|
|
|
useEffect(() => {
|
2025-10-16 15:05:24 +09:00
|
|
|
loadFlows();
|
|
|
|
|
}, [loadFlows]);
|
2025-09-09 13:10:03 +09:00
|
|
|
|
2025-10-16 15:05:24 +09:00
|
|
|
const handleDelete = (flow: NodeFlow) => {
|
|
|
|
|
setSelectedFlow(flow);
|
2025-09-09 13:10:03 +09:00
|
|
|
setShowDeleteModal(true);
|
2025-09-09 11:35:05 +09:00
|
|
|
};
|
|
|
|
|
|
2025-10-16 15:05:24 +09:00
|
|
|
const handleCopy = async (flow: NodeFlow) => {
|
2025-09-09 13:10:03 +09:00
|
|
|
try {
|
|
|
|
|
setLoading(true);
|
2025-10-16 15:05:24 +09:00
|
|
|
const response = await apiClient.get(`/dataflow/node-flows/${flow.flowId}`);
|
2026-03-19 15:07:07 +09:00
|
|
|
if (!response.data.success) throw new Error(response.data.message || "플로우 조회 실패");
|
2025-10-16 15:05:24 +09:00
|
|
|
|
|
|
|
|
const copyResponse = await apiClient.post("/dataflow/node-flows", {
|
|
|
|
|
flowName: `${flow.flowName} (복사본)`,
|
|
|
|
|
flowDescription: flow.flowDescription,
|
2026-03-19 15:07:07 +09:00
|
|
|
flowData: response.data.data.flowData,
|
2025-10-16 15:05:24 +09:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (copyResponse.data.success) {
|
2026-03-19 15:07:07 +09:00
|
|
|
toast.success("플로우를 복사했어요");
|
2025-10-16 15:05:24 +09:00
|
|
|
await loadFlows();
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(copyResponse.data.message || "플로우 복사 실패");
|
|
|
|
|
}
|
2025-09-09 13:10:03 +09:00
|
|
|
} catch (error) {
|
2025-10-16 15:05:24 +09:00
|
|
|
console.error("플로우 복사 실패:", error);
|
2026-03-19 15:07:07 +09:00
|
|
|
showErrorToast("플로우 복사에 실패했어요", error, {
|
|
|
|
|
guidance: "잠시 후 다시 시도해 주세요.",
|
|
|
|
|
});
|
2025-09-09 13:10:03 +09:00
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleConfirmDelete = async () => {
|
2025-10-16 15:05:24 +09:00
|
|
|
if (!selectedFlow) return;
|
2025-09-09 13:10:03 +09:00
|
|
|
try {
|
|
|
|
|
setLoading(true);
|
2025-10-16 15:05:24 +09:00
|
|
|
const response = await apiClient.delete(`/dataflow/node-flows/${selectedFlow.flowId}`);
|
|
|
|
|
if (response.data.success) {
|
2026-03-19 15:07:07 +09:00
|
|
|
toast.success(`"${selectedFlow.flowName}" 플로우를 삭제했어요`);
|
2025-10-16 15:05:24 +09:00
|
|
|
await loadFlows();
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(response.data.message || "플로우 삭제 실패");
|
|
|
|
|
}
|
2025-09-09 13:10:03 +09:00
|
|
|
} catch (error) {
|
2025-10-16 15:05:24 +09:00
|
|
|
console.error("플로우 삭제 실패:", error);
|
2026-03-19 15:07:07 +09:00
|
|
|
showErrorToast("플로우 삭제에 실패했어요", error, {
|
|
|
|
|
guidance: "잠시 후 다시 시도해 주세요.",
|
|
|
|
|
});
|
2025-09-09 13:10:03 +09:00
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
setShowDeleteModal(false);
|
2025-10-16 15:05:24 +09:00
|
|
|
setSelectedFlow(null);
|
2025-09-09 13:10:03 +09:00
|
|
|
}
|
2025-09-09 11:35:05 +09:00
|
|
|
};
|
|
|
|
|
|
2026-03-19 15:07:07 +09:00
|
|
|
const filteredFlows = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
flows.filter(
|
|
|
|
|
(f) =>
|
|
|
|
|
f.flowName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
|
|
|
(f.flowDescription || "").toLowerCase().includes(searchTerm.toLowerCase()),
|
|
|
|
|
),
|
|
|
|
|
[flows, searchTerm],
|
2025-10-16 15:05:24 +09:00
|
|
|
);
|
2025-09-09 11:35:05 +09:00
|
|
|
|
2026-03-19 15:07:07 +09:00
|
|
|
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]);
|
2026-03-09 22:07:11 +09:00
|
|
|
|
2026-03-19 15:07:07 +09:00
|
|
|
if (loading && flows.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-64 items-center justify-center">
|
|
|
|
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-09 22:07:11 +09:00
|
|
|
|
2025-09-09 11:35:05 +09:00
|
|
|
return (
|
2026-03-19 15:07:07 +09:00
|
|
|
<div className="space-y-6">
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="bg-gradient-to-r from-zinc-100 to-violet-300 bg-clip-text text-2xl font-bold tracking-tight text-transparent sm:text-3xl">
|
|
|
|
|
제어 관리
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="mt-1 text-xs text-zinc-500 sm:text-sm">
|
|
|
|
|
노드 기반 데이터 플로우를 시각적으로 설계하고 관리해요
|
|
|
|
|
</p>
|
2025-10-22 14:52:13 +09:00
|
|
|
</div>
|
2026-03-19 15:07:07 +09:00
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={loadFlows}
|
|
|
|
|
disabled={loading}
|
|
|
|
|
className="gap-1.5 border-zinc-700 bg-zinc-900 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
|
|
|
|
새로고침
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => onLoadFlow(null)}
|
|
|
|
|
className="gap-1.5 bg-violet-600 font-semibold text-white shadow-lg shadow-violet-600/25 hover:bg-violet-500"
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-3.5 w-3.5" />
|
|
|
|
|
새 플로우
|
2025-10-22 14:52:13 +09:00
|
|
|
</Button>
|
2025-09-09 11:35:05 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-19 15:07:07 +09:00
|
|
|
{/* 통계 스트립 */}
|
|
|
|
|
<div className="flex flex-wrap items-center gap-5 border-b border-zinc-800/60 pb-4">
|
|
|
|
|
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
|
|
|
|
<div className="h-1.5 w-1.5 rounded-full bg-violet-500" />
|
|
|
|
|
전체{" "}
|
|
|
|
|
<strong className="font-mono font-bold text-zinc-200">{stats.total}</strong>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
|
|
|
|
<div className="h-1.5 w-1.5 rounded-full bg-zinc-600" />
|
|
|
|
|
총 노드{" "}
|
|
|
|
|
<strong className="font-mono font-bold text-zinc-300">{stats.totalNodes}</strong>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
|
|
|
|
<div className="h-1.5 w-1.5 rounded-full bg-zinc-600" />
|
|
|
|
|
총 연결{" "}
|
|
|
|
|
<strong className="font-mono font-bold text-zinc-300">{stats.totalEdges}</strong>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 툴바 */}
|
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
|
|
|
<div className="relative min-w-[200px] flex-1 sm:max-w-[360px]">
|
|
|
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-600" />
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="플로우 검색..."
|
|
|
|
|
value={searchTerm}
|
|
|
|
|
onChange={(e) => 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"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-0.5 rounded-lg border border-zinc-700 bg-zinc-900 p-0.5">
|
|
|
|
|
<button
|
|
|
|
|
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
|
|
|
viewMode === "grid"
|
|
|
|
|
? "bg-violet-500/10 text-violet-400"
|
|
|
|
|
: "text-zinc-500 hover:text-zinc-300"
|
|
|
|
|
}`}
|
|
|
|
|
onClick={() => setViewMode("grid")}
|
|
|
|
|
>
|
|
|
|
|
<LayoutGrid className="h-3.5 w-3.5" />
|
|
|
|
|
그리드
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
|
|
|
viewMode === "list"
|
|
|
|
|
? "bg-violet-500/10 text-violet-400"
|
|
|
|
|
: "text-zinc-500 hover:text-zinc-300"
|
|
|
|
|
}`}
|
|
|
|
|
onClick={() => setViewMode("list")}
|
|
|
|
|
>
|
|
|
|
|
<List className="h-3.5 w-3.5" />
|
|
|
|
|
리스트
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 컨텐츠 */}
|
|
|
|
|
{filteredFlows.length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-zinc-700 px-6 py-20 text-center">
|
|
|
|
|
<div className="mb-5 flex h-20 w-20 items-center justify-center rounded-2xl border border-violet-500/15 bg-violet-500/[0.08]">
|
|
|
|
|
<Network className="h-9 w-9 text-violet-400" />
|
|
|
|
|
</div>
|
|
|
|
|
<h2 className="mb-2 text-lg font-bold text-zinc-200">
|
|
|
|
|
{searchTerm ? "검색 결과가 없어요" : "아직 플로우가 없어요"}
|
|
|
|
|
</h2>
|
|
|
|
|
<p className="mb-6 max-w-sm text-sm leading-relaxed text-zinc-500">
|
|
|
|
|
{searchTerm
|
|
|
|
|
? `"${searchTerm}"에 해당하는 플로우를 찾지 못했어요. 다른 키워드로 검색해 보세요.`
|
|
|
|
|
: "노드를 연결해서 데이터 처리 파이프라인을 만들어 보세요. 코드 없이 드래그 앤 드롭만으로 설계할 수 있어요."}
|
|
|
|
|
</p>
|
|
|
|
|
{!searchTerm && (
|
|
|
|
|
<Button
|
|
|
|
|
onClick={() => onLoadFlow(null)}
|
|
|
|
|
className="gap-2 bg-violet-600 px-5 font-semibold text-white shadow-lg shadow-violet-600/25 hover:bg-violet-500"
|
|
|
|
|
>
|
2025-10-22 14:52:13 +09:00
|
|
|
<Plus className="h-4 w-4" />
|
2026-03-19 15:07:07 +09:00
|
|
|
첫 번째 플로우 만들기
|
2025-10-22 14:52:13 +09:00
|
|
|
</Button>
|
2026-03-19 15:07:07 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
) : viewMode === "grid" ? (
|
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
|
|
|
{filteredFlows.map((flow) => (
|
|
|
|
|
<FlowCard
|
|
|
|
|
key={flow.flowId}
|
|
|
|
|
flow={flow}
|
|
|
|
|
onOpen={() => onLoadFlow(flow.flowId)}
|
|
|
|
|
onCopy={() => handleCopy(flow)}
|
|
|
|
|
onDelete={() => handleDelete(flow)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
{/* 새 플로우 만들기 카드 */}
|
|
|
|
|
<div
|
|
|
|
|
className="group flex min-h-[260px] cursor-pointer flex-col items-center justify-center rounded-xl border border-dashed border-zinc-700 transition-all duration-200 hover:border-violet-500/50 hover:bg-violet-500/[0.04]"
|
|
|
|
|
onClick={() => onLoadFlow(null)}
|
|
|
|
|
>
|
|
|
|
|
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-violet-500/[0.08]">
|
|
|
|
|
<Plus className="h-6 w-6 text-violet-400" />
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-sm font-semibold text-zinc-400 group-hover:text-zinc-200">
|
|
|
|
|
새 플로우 만들기
|
|
|
|
|
</span>
|
|
|
|
|
<span className="mt-1 text-[11px] text-zinc-600">빈 캔버스에서 시작해요</span>
|
2025-10-22 14:52:13 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-03-19 15:07:07 +09:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
{filteredFlows.map((flow) => (
|
|
|
|
|
<div
|
|
|
|
|
key={flow.flowId}
|
|
|
|
|
className="group flex cursor-pointer items-center gap-4 rounded-lg border border-zinc-800 bg-zinc-900/80 px-4 py-3 transition-all hover:border-violet-500/40 hover:bg-zinc-900"
|
|
|
|
|
onClick={() => onLoadFlow(flow.flowId)}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-violet-500/10">
|
|
|
|
|
<Network className="h-5 w-5 text-violet-400" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<h3 className="truncate text-sm font-semibold text-zinc-100">
|
|
|
|
|
{flow.flowName}
|
|
|
|
|
</h3>
|
|
|
|
|
<p className="truncate text-xs text-zinc-500">
|
|
|
|
|
{flow.flowDescription || "설명이 아직 없어요"}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="hidden items-center gap-1.5 lg:flex">
|
|
|
|
|
{Object.entries(flow.summary?.nodeTypes || {})
|
|
|
|
|
.slice(0, 3)
|
|
|
|
|
.map(([type, count]) => {
|
|
|
|
|
const colors = getNodeCategoryColor(type);
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
key={type}
|
|
|
|
|
className={`inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${colors.text} ${colors.bg} ${colors.border}`}
|
|
|
|
|
>
|
|
|
|
|
{getNodeLabel(type)} {count}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
<span className="hidden font-mono text-[11px] text-zinc-600 sm:block">
|
|
|
|
|
{relativeTime(flow.updatedAt)}
|
|
|
|
|
</span>
|
|
|
|
|
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
<button
|
|
|
|
|
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-violet-500/10 hover:text-violet-400"
|
|
|
|
|
title="복사"
|
|
|
|
|
onClick={() => handleCopy(flow)}
|
|
|
|
|
>
|
|
|
|
|
<Copy className="h-3.5 w-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-pink-500/10 hover:text-pink-400"
|
|
|
|
|
title="삭제"
|
|
|
|
|
onClick={() => handleDelete(flow)}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2025-10-22 14:52:13 +09:00
|
|
|
)}
|
2025-09-09 11:35:05 +09:00
|
|
|
|
2025-09-09 13:10:03 +09:00
|
|
|
{/* 삭제 확인 모달 */}
|
|
|
|
|
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
2025-10-22 14:52:13 +09:00
|
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
2025-09-09 13:10:03 +09:00
|
|
|
<DialogHeader>
|
2026-03-19 15:07:07 +09:00
|
|
|
<DialogTitle className="text-base sm:text-lg">플로우를 삭제할까요?</DialogTitle>
|
2025-10-22 14:52:13 +09:00
|
|
|
<DialogDescription className="text-xs sm:text-sm">
|
2026-03-19 15:07:07 +09:00
|
|
|
“{selectedFlow?.flowName}” 플로우가 완전히 삭제돼요.
|
2025-09-09 13:10:03 +09:00
|
|
|
<br />
|
2026-03-19 15:07:07 +09:00
|
|
|
<span className="text-destructive font-medium">
|
|
|
|
|
이 작업은 되돌릴 수 없으며, 모든 노드와 연결 정보가 함께 삭제돼요.
|
2025-09-09 13:10:03 +09:00
|
|
|
</span>
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
2025-10-22 14:52:13 +09:00
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => setShowDeleteModal(false)}
|
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
>
|
2025-09-09 13:10:03 +09:00
|
|
|
취소
|
|
|
|
|
</Button>
|
2025-10-22 14:52:13 +09:00
|
|
|
<Button
|
|
|
|
|
variant="destructive"
|
|
|
|
|
onClick={handleConfirmDelete}
|
|
|
|
|
disabled={loading}
|
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
>
|
2025-09-09 13:10:03 +09:00
|
|
|
{loading ? "삭제 중..." : "삭제"}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
2025-09-09 11:35:05 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|