- Replaced existing toast error messages with the new `showErrorToast` utility across multiple components, improving consistency in error reporting. - Updated error messages to provide more specific guidance for users, enhancing the overall user experience during error scenarios. - Ensured that all relevant error handling in batch management, external call configurations, cascading management, and screen management components now utilizes the new utility for better maintainability.
426 lines
18 KiB
TypeScript
426 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { MoreHorizontal, Trash2, Copy, Plus, Search, Network, Database, Calendar, User } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
// 노드 플로우 타입 정의
|
|
interface NodeFlow {
|
|
flowId: number;
|
|
flowName: string;
|
|
flowDescription: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface DataFlowListProps {
|
|
onLoadFlow: (flowId: number | null) => void;
|
|
}
|
|
|
|
export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
|
const { user } = useAuth();
|
|
const [flows, setFlows] = useState<NodeFlow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
|
|
// 모달 상태
|
|
const [showCopyModal, setShowCopyModal] = useState(false);
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
const [selectedFlow, setSelectedFlow] = useState<NodeFlow | null>(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 originalFlow = response.data.data;
|
|
|
|
// 복사본 저장
|
|
const copyResponse = await apiClient.post("/dataflow/node-flows", {
|
|
flowName: `${flow.flowName} (복사본)`,
|
|
flowDescription: flow.flowDescription,
|
|
flowData: originalFlow.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 = flows.filter(
|
|
(flow) =>
|
|
flow.flowName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
flow.flowDescription.toLowerCase().includes(searchTerm.toLowerCase()),
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 검색 및 액션 영역 */}
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
{/* 검색 영역 */}
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
|
<div className="w-full sm:w-[400px]">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
placeholder="플로우명, 설명으로 검색..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="h-10 pl-10 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 액션 버튼 영역 */}
|
|
<div className="flex items-center gap-4">
|
|
<div className="text-sm text-muted-foreground">
|
|
총 <span className="font-semibold text-foreground">{filteredFlows.length}</span> 건
|
|
</div>
|
|
<Button onClick={() => onLoadFlow(null)} className="h-10 gap-2 text-sm font-medium">
|
|
<Plus className="h-4 w-4" />
|
|
새 플로우 생성
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<>
|
|
{/* 데스크톱 테이블 스켈레톤 */}
|
|
<div className="hidden bg-card shadow-sm lg:block">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-background">
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">플로우명</TableHead>
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">설명</TableHead>
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">생성일</TableHead>
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">최근 수정</TableHead>
|
|
<TableHead className="h-12 px-6 py-3 text-right text-sm font-semibold">작업</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{Array.from({ length: 5 }).map((_, index) => (
|
|
<TableRow key={index} className="bg-background">
|
|
<TableCell className="h-16 px-6 py-3">
|
|
<div className="h-4 w-32 animate-pulse rounded bg-muted"></div>
|
|
</TableCell>
|
|
<TableCell className="h-16 px-6 py-3">
|
|
<div className="h-4 w-48 animate-pulse rounded bg-muted"></div>
|
|
</TableCell>
|
|
<TableCell className="h-16 px-6 py-3">
|
|
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
|
|
</TableCell>
|
|
<TableCell className="h-16 px-6 py-3">
|
|
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
|
|
</TableCell>
|
|
<TableCell className="h-16 px-6 py-3">
|
|
<div className="flex justify-end">
|
|
<div className="h-8 w-8 animate-pulse rounded bg-muted"></div>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* 모바일/태블릿 카드 스켈레톤 */}
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
|
{Array.from({ length: 4 }).map((_, index) => (
|
|
<div key={index} className="rounded-lg border bg-card p-4 shadow-sm">
|
|
<div className="mb-4 flex items-start justify-between">
|
|
<div className="flex-1 space-y-2">
|
|
<div className="h-5 w-32 animate-pulse rounded bg-muted"></div>
|
|
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2 border-t pt-4">
|
|
<div className="flex justify-between">
|
|
<div className="h-4 w-16 animate-pulse rounded bg-muted"></div>
|
|
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<div className="h-4 w-16 animate-pulse rounded bg-muted"></div>
|
|
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : filteredFlows.length === 0 ? (
|
|
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
|
<div className="flex flex-col items-center gap-2 text-center">
|
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
|
<Network className="h-8 w-8 text-muted-foreground" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold">플로우가 없습니다</h3>
|
|
<p className="max-w-sm text-sm text-muted-foreground">
|
|
새 플로우를 생성하여 노드 기반 데이터 제어를 설계해보세요.
|
|
</p>
|
|
<Button onClick={() => onLoadFlow(null)} className="mt-4 h-10 gap-2 text-sm font-medium">
|
|
<Plus className="h-4 w-4" />
|
|
새 플로우 생성
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
|
<div className="hidden bg-card shadow-sm lg:block">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-background">
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">플로우명</TableHead>
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">설명</TableHead>
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">생성일</TableHead>
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">최근 수정</TableHead>
|
|
<TableHead className="h-12 px-6 py-3 text-right text-sm font-semibold">작업</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredFlows.map((flow) => (
|
|
<TableRow
|
|
key={flow.flowId}
|
|
className="bg-background transition-colors hover:bg-muted/50 cursor-pointer"
|
|
onClick={() => onLoadFlow(flow.flowId)}
|
|
>
|
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
|
<div className="flex items-center font-medium">
|
|
<Network className="mr-2 h-4 w-4 text-primary" />
|
|
{flow.flowName}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
|
<div className="text-muted-foreground">{flow.flowDescription || "설명 없음"}</div>
|
|
</TableCell>
|
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
|
<div className="flex items-center text-muted-foreground">
|
|
<Calendar className="mr-1 h-3 w-3" />
|
|
{new Date(flow.createdAt).toLocaleDateString()}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
|
<div className="flex items-center text-muted-foreground">
|
|
<Calendar className="mr-1 h-3 w-3" />
|
|
{new Date(flow.updatedAt).toLocaleDateString()}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="h-16 px-6 py-3" onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex justify-end">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => onLoadFlow(flow.flowId)}>
|
|
<Network className="mr-2 h-4 w-4" />
|
|
불러오기
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleCopy(flow)}>
|
|
<Copy className="mr-2 h-4 w-4" />
|
|
복사
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleDelete(flow)} className="text-destructive">
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
삭제
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
|
{filteredFlows.map((flow) => (
|
|
<div
|
|
key={flow.flowId}
|
|
className="cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-muted/50"
|
|
onClick={() => onLoadFlow(flow.flowId)}
|
|
>
|
|
{/* 헤더 */}
|
|
<div className="mb-4 flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center">
|
|
<Network className="mr-2 h-4 w-4 text-primary" />
|
|
<h3 className="text-base font-semibold">{flow.flowName}</h3>
|
|
</div>
|
|
<p className="mt-1 text-sm text-muted-foreground">{flow.flowDescription || "설명 없음"}</p>
|
|
</div>
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => onLoadFlow(flow.flowId)}>
|
|
<Network className="mr-2 h-4 w-4" />
|
|
불러오기
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleCopy(flow)}>
|
|
<Copy className="mr-2 h-4 w-4" />
|
|
복사
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleDelete(flow)} className="text-destructive">
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
삭제
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 정보 */}
|
|
<div className="space-y-2 border-t pt-4">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">생성일</span>
|
|
<span className="font-medium">{new Date(flow.createdAt).toLocaleDateString()}</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">최근 수정</span>
|
|
<span className="font-medium">{new Date(flow.updatedAt).toLocaleDateString()}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* 삭제 확인 모달 */}
|
|
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
|
<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?.flowName}” 플로우를 완전히 삭제하시겠습니까?
|
|
<br />
|
|
<span className="font-medium text-destructive">
|
|
이 작업은 되돌릴 수 없으며, 모든 플로우 정보가 영구적으로 삭제됩니다.
|
|
</span>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<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"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleConfirmDelete}
|
|
disabled={loading}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
{loading ? "삭제 중..." : "삭제"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|