- 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.
510 lines
18 KiB
TypeScript
510 lines
18 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useEffect } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import {
|
||
Plus,
|
||
Search,
|
||
MoreHorizontal,
|
||
Edit,
|
||
Trash2,
|
||
Play,
|
||
RefreshCw,
|
||
BarChart3,
|
||
ArrowRight,
|
||
Database,
|
||
Globe
|
||
} from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { BatchAPI, BatchJob } from "@/lib/api/batch";
|
||
import BatchJobModal from "@/components/admin/BatchJobModal";
|
||
|
||
export default function BatchManagementPage() {
|
||
const router = useRouter();
|
||
const [jobs, setJobs] = useState<BatchJob[]>([]);
|
||
const [filteredJobs, setFilteredJobs] = useState<BatchJob[]>([]);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [searchTerm, setSearchTerm] = useState("");
|
||
const [statusFilter, setStatusFilter] = useState("all");
|
||
const [typeFilter, setTypeFilter] = useState("all");
|
||
const [jobTypes, setJobTypes] = useState<Array<{ value: string; label: string }>>([]);
|
||
|
||
// 모달 상태
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [selectedJob, setSelectedJob] = useState<BatchJob | null>(null);
|
||
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
loadJobs();
|
||
loadJobTypes();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
filterJobs();
|
||
}, [jobs, searchTerm, statusFilter, typeFilter]);
|
||
|
||
const loadJobs = async () => {
|
||
setIsLoading(true);
|
||
try {
|
||
const data = await BatchAPI.getBatchJobs();
|
||
setJobs(data);
|
||
} catch (error) {
|
||
console.error("배치 작업 목록 조회 오류:", error);
|
||
showErrorToast("배치 작업 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadJobTypes = async () => {
|
||
try {
|
||
const types = await BatchAPI.getSupportedJobTypes();
|
||
setJobTypes(types);
|
||
} catch (error) {
|
||
console.error("작업 타입 조회 오류:", error);
|
||
}
|
||
};
|
||
|
||
const filterJobs = () => {
|
||
let filtered = jobs;
|
||
|
||
// 검색어 필터
|
||
if (searchTerm) {
|
||
filtered = filtered.filter(job =>
|
||
job.job_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
job.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
||
);
|
||
}
|
||
|
||
// 상태 필터
|
||
if (statusFilter !== "all") {
|
||
filtered = filtered.filter(job => job.is_active === statusFilter);
|
||
}
|
||
|
||
// 타입 필터
|
||
if (typeFilter !== "all") {
|
||
filtered = filtered.filter(job => job.job_type === typeFilter);
|
||
}
|
||
|
||
setFilteredJobs(filtered);
|
||
};
|
||
|
||
const handleCreate = () => {
|
||
setIsBatchTypeModalOpen(true);
|
||
};
|
||
|
||
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
|
||
console.log("배치 타입 선택:", type);
|
||
setIsBatchTypeModalOpen(false);
|
||
|
||
if (type === 'db-to-db') {
|
||
// 기존 배치 생성 모달 열기
|
||
console.log("DB → DB 배치 모달 열기");
|
||
setSelectedJob(null);
|
||
setIsModalOpen(true);
|
||
} else if (type === 'restapi-to-db') {
|
||
// 새로운 REST API 배치 페이지로 이동
|
||
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
|
||
router.push('/admin/batch-management-new');
|
||
}
|
||
};
|
||
|
||
const handleEdit = (job: BatchJob) => {
|
||
setSelectedJob(job);
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const handleDelete = async (job: BatchJob) => {
|
||
if (!confirm(`"${job.job_name}" 배치 작업을 삭제하시겠습니까?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await BatchAPI.deleteBatchJob(job.id!);
|
||
toast.success("배치 작업이 삭제되었습니다.");
|
||
loadJobs();
|
||
} catch (error) {
|
||
console.error("배치 작업 삭제 오류:", error);
|
||
showErrorToast("배치 작업 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||
}
|
||
};
|
||
|
||
const handleExecute = async (job: BatchJob) => {
|
||
try {
|
||
await BatchAPI.executeBatchJob(job.id!);
|
||
toast.success(`"${job.job_name}" 배치 작업을 실행했습니다.`);
|
||
} catch (error) {
|
||
console.error("배치 작업 실행 오류:", error);
|
||
showErrorToast("배치 작업 실행에 실패했습니다", error, { guidance: "배치 설정을 확인해 주세요." });
|
||
}
|
||
};
|
||
|
||
const handleModalSave = () => {
|
||
loadJobs();
|
||
};
|
||
|
||
const getStatusBadge = (isActive: string) => {
|
||
return isActive === "Y" ? (
|
||
<Badge className="bg-green-100 text-green-800">활성</Badge>
|
||
) : (
|
||
<Badge className="bg-red-100 text-red-800">비활성</Badge>
|
||
);
|
||
};
|
||
|
||
const getTypeBadge = (type: string) => {
|
||
const option = jobTypes.find(opt => opt.value === type);
|
||
const colors = {
|
||
collection: "bg-blue-100 text-blue-800",
|
||
sync: "bg-purple-100 text-purple-800",
|
||
cleanup: "bg-orange-100 text-orange-800",
|
||
custom: "bg-gray-100 text-gray-800",
|
||
};
|
||
|
||
const icons = {
|
||
collection: "📥",
|
||
sync: "🔄",
|
||
cleanup: "🧹",
|
||
custom: "⚙️",
|
||
};
|
||
|
||
return (
|
||
<Badge className={colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800"}>
|
||
<span className="mr-1">{icons[type as keyof typeof icons] || "📋"}</span>
|
||
{option?.label || type}
|
||
</Badge>
|
||
);
|
||
};
|
||
|
||
const getSuccessRate = (job: BatchJob) => {
|
||
if (job.execution_count === 0) return 100;
|
||
return Math.round((job.success_count / job.execution_count) * 100);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* 헤더 */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold">배치 관리</h1>
|
||
<p className="text-muted-foreground">
|
||
스케줄된 배치 작업을 관리하고 실행 상태를 모니터링합니다.
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" onClick={() => window.open('/admin/monitoring', '_blank')}>
|
||
<BarChart3 className="h-4 w-4 mr-2" />
|
||
모니터링
|
||
</Button>
|
||
<Button onClick={handleCreate}>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
새 배치 작업
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 통계 카드 */}
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||
<CardTitle className="text-sm font-medium">총 작업</CardTitle>
|
||
<div className="text-2xl">📋</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="text-2xl font-bold">{jobs.length}</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
활성: {jobs.filter(j => j.is_active === 'Y').length}개
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||
<CardTitle className="text-sm font-medium">총 실행</CardTitle>
|
||
<div className="text-2xl">▶️</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="text-2xl font-bold">
|
||
{jobs.reduce((sum, job) => sum + job.execution_count, 0)}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">누적 실행 횟수</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||
<CardTitle className="text-sm font-medium">성공</CardTitle>
|
||
<div className="text-2xl">✅</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="text-2xl font-bold text-green-600">
|
||
{jobs.reduce((sum, job) => sum + job.success_count, 0)}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">총 성공 횟수</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||
<CardTitle className="text-sm font-medium">실패</CardTitle>
|
||
<div className="text-2xl">❌</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="text-2xl font-bold text-red-600">
|
||
{jobs.reduce((sum, job) => sum + job.failure_count, 0)}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">총 실패 횟수</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* 필터 및 검색 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>필터 및 검색</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="flex flex-col md:flex-row gap-4">
|
||
<div className="flex-1">
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="작업명, 설명으로 검색..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="pl-10"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||
<SelectTrigger className="w-32">
|
||
<SelectValue placeholder="상태" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">전체</SelectItem>
|
||
<SelectItem value="Y">활성</SelectItem>
|
||
<SelectItem value="N">비활성</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||
<SelectTrigger className="w-40">
|
||
<SelectValue placeholder="작업 타입" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">전체 타입</SelectItem>
|
||
{jobTypes.map((type) => (
|
||
<SelectItem key={type.value} value={type.value}>
|
||
{type.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
<Button variant="outline" onClick={loadJobs} disabled={isLoading}>
|
||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||
새로고침
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 배치 작업 목록 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>배치 작업 목록 ({filteredJobs.length}개)</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{isLoading ? (
|
||
<div className="text-center py-8">
|
||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
||
<p>배치 작업을 불러오는 중...</p>
|
||
</div>
|
||
) : filteredJobs.length === 0 ? (
|
||
<div className="text-center py-8 text-muted-foreground">
|
||
{jobs.length === 0 ? "배치 작업이 없습니다." : "검색 결과가 없습니다."}
|
||
</div>
|
||
) : (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<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-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>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{filteredJobs.map((job) => (
|
||
<TableRow key={job.id}>
|
||
<TableCell>
|
||
<div>
|
||
<div className="font-medium">{job.job_name}</div>
|
||
{job.description && (
|
||
<div className="text-sm text-muted-foreground">
|
||
{job.description}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
{getTypeBadge(job.job_type)}
|
||
</TableCell>
|
||
<TableCell className="font-mono text-sm">
|
||
{job.schedule_cron || "-"}
|
||
</TableCell>
|
||
<TableCell>
|
||
{getStatusBadge(job.is_active)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="text-sm">
|
||
<div>총 {job.execution_count}회</div>
|
||
<div className="text-muted-foreground">
|
||
성공 {job.success_count} / 실패 {job.failure_count}
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex items-center gap-2">
|
||
<div className={`text-sm font-medium ${
|
||
getSuccessRate(job) >= 90 ? 'text-green-600' :
|
||
getSuccessRate(job) >= 70 ? 'text-yellow-600' : 'text-red-600'
|
||
}`}>
|
||
{getSuccessRate(job)}%
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
{job.last_executed_at
|
||
? new Date(job.last_executed_at).toLocaleString()
|
||
: "-"}
|
||
</TableCell>
|
||
<TableCell>
|
||
<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={() => handleEdit(job)}>
|
||
<Edit className="h-4 w-4 mr-2" />
|
||
수정
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
onClick={() => handleExecute(job)}
|
||
disabled={job.is_active !== "Y"}
|
||
>
|
||
<Play className="h-4 w-4 mr-2" />
|
||
실행
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={() => handleDelete(job)}>
|
||
<Trash2 className="h-4 w-4 mr-2" />
|
||
삭제
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 배치 타입 선택 모달 */}
|
||
{isBatchTypeModalOpen && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<Card className="w-full max-w-2xl mx-4">
|
||
<CardHeader>
|
||
<CardTitle className="text-center">배치 타입 선택</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{/* DB → DB */}
|
||
<div
|
||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-blue-500 hover:bg-blue-50"
|
||
onClick={() => handleBatchTypeSelect('db-to-db')}
|
||
>
|
||
<div className="flex items-center justify-center mb-4">
|
||
<Database className="w-8 h-8 text-blue-600 mr-2" />
|
||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
||
<Database className="w-8 h-8 text-blue-600" />
|
||
</div>
|
||
<div className="text-center">
|
||
<div className="font-medium text-lg mb-2">DB → DB</div>
|
||
<div className="text-sm text-gray-500">데이터베이스 간 데이터 동기화</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* REST API → DB */}
|
||
<div
|
||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-green-500 hover:bg-green-50"
|
||
onClick={() => handleBatchTypeSelect('restapi-to-db')}
|
||
>
|
||
<div className="flex items-center justify-center mb-4">
|
||
<Globe className="w-8 h-8 text-green-600 mr-2" />
|
||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
||
<Database className="w-8 h-8 text-green-600" />
|
||
</div>
|
||
<div className="text-center">
|
||
<div className="font-medium text-lg mb-2">REST API → DB</div>
|
||
<div className="text-sm text-gray-500">REST API에서 데이터베이스로 데이터 수집</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-center pt-4">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setIsBatchTypeModalOpen(false)}
|
||
>
|
||
취소
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
|
||
{/* 배치 작업 모달 */}
|
||
<BatchJobModal
|
||
isOpen={isModalOpen}
|
||
onClose={() => setIsModalOpen(false)}
|
||
onSave={handleModalSave}
|
||
job={selectedJob}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|