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

478 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-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-muted-foreground text-xs">: {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-muted-foreground text-xs"> </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-muted-foreground text-xs"> </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-muted-foreground text-xs"> </p>
</CardContent>
</Card>
</div>
{/* 필터 및 검색 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4 md:flex-row">
<div className="flex-1">
<div className="relative">
<Search className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
<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={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button>
</div>
</CardContent>
</Card>
{/* 배치 작업 목록 */}
<Card>
<CardHeader>
<CardTitle> ({filteredJobs.length})</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-8 text-center">
<RefreshCw className="mx-auto mb-2 h-8 w-8 animate-spin" />
<p> ...</p>
</div>
) : filteredJobs.length === 0 ? (
<div className="text-muted-foreground py-8 text-center">
{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-muted-foreground text-sm">{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="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExecute(job)} disabled={job.is_active !== "Y"}>
<Play className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(job)}>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* 배치 타입 선택 모달 */}
{isBatchTypeModalOpen && (
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
<Card className="mx-4 w-full max-w-2xl">
<CardHeader>
<CardTitle className="text-center"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* DB → DB */}
<div
className="cursor-pointer rounded-lg border p-6 transition-all hover:border-blue-500 hover:bg-blue-50"
onClick={() => handleBatchTypeSelect("db-to-db")}
>
<div className="mb-4 flex items-center justify-center">
<Database className="mr-2 h-8 w-8 text-blue-600" />
<ArrowRight className="mr-2 h-6 w-6 text-gray-400" />
<Database className="h-8 w-8 text-blue-600" />
</div>
<div className="text-center">
<div className="mb-2 text-lg font-medium">DB DB</div>
<div className="text-sm text-gray-500"> </div>
</div>
</div>
{/* REST API → DB */}
<div
className="cursor-pointer rounded-lg border p-6 transition-all hover:border-green-500 hover:bg-green-50"
onClick={() => handleBatchTypeSelect("restapi-to-db")}
>
<div className="mb-4 flex items-center justify-center">
<Globe className="mr-2 h-8 w-8 text-green-600" />
<ArrowRight className="mr-2 h-6 w-6 text-gray-400" />
<Database className="h-8 w-8 text-green-600" />
</div>
<div className="text-center">
<div className="mb-2 text-lg font-medium">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>
);
}