356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
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 {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Plus,
|
|
Search,
|
|
Play,
|
|
Pause,
|
|
Edit,
|
|
Trash2,
|
|
RefreshCw,
|
|
Clock,
|
|
Database,
|
|
ArrowRight
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { useRouter } from "next/navigation";
|
|
import {
|
|
BatchAPI,
|
|
BatchConfig,
|
|
BatchMapping,
|
|
} from "@/lib/api/batch";
|
|
|
|
export default function BatchManagementPage() {
|
|
const router = useRouter();
|
|
|
|
// 상태 관리
|
|
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
|
|
|
// 페이지 로드 시 배치 목록 조회
|
|
useEffect(() => {
|
|
loadBatchConfigs();
|
|
}, [currentPage, searchTerm]);
|
|
|
|
// 배치 설정 목록 조회
|
|
const loadBatchConfigs = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await BatchAPI.getBatchConfigs({
|
|
page: currentPage,
|
|
limit: 10,
|
|
search: searchTerm || undefined,
|
|
});
|
|
|
|
if (response.success && response.data) {
|
|
setBatchConfigs(response.data);
|
|
if (response.pagination) {
|
|
setTotalPages(response.pagination.totalPages);
|
|
}
|
|
} else {
|
|
setBatchConfigs([]);
|
|
}
|
|
} catch (error) {
|
|
console.error("배치 목록 조회 실패:", error);
|
|
toast.error("배치 목록을 불러오는데 실패했습니다.");
|
|
setBatchConfigs([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// 배치 수동 실행
|
|
const executeBatch = async (batchId: number) => {
|
|
setExecutingBatch(batchId);
|
|
try {
|
|
const response = await BatchAPI.executeBatchConfig(batchId);
|
|
if (response.success) {
|
|
toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`);
|
|
} else {
|
|
toast.error("배치 실행에 실패했습니다.");
|
|
}
|
|
} catch (error) {
|
|
console.error("배치 실행 실패:", error);
|
|
toast.error("배치 실행 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setExecutingBatch(null);
|
|
}
|
|
};
|
|
|
|
// 배치 활성화/비활성화 토글
|
|
const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
|
|
try {
|
|
const newStatus = currentStatus === 'Y' ? 'N' : 'Y';
|
|
await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus });
|
|
toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`);
|
|
loadBatchConfigs(); // 목록 새로고침
|
|
} catch (error) {
|
|
console.error("배치 상태 변경 실패:", error);
|
|
toast.error("배치 상태 변경에 실패했습니다.");
|
|
}
|
|
};
|
|
|
|
// 배치 삭제
|
|
const deleteBatch = async (batchId: number, batchName: string) => {
|
|
if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await BatchAPI.deleteBatchConfig(batchId);
|
|
toast.success("배치가 삭제되었습니다.");
|
|
loadBatchConfigs(); // 목록 새로고침
|
|
} catch (error) {
|
|
console.error("배치 삭제 실패:", error);
|
|
toast.error("배치 삭제에 실패했습니다.");
|
|
}
|
|
};
|
|
|
|
// 검색 처리
|
|
const handleSearch = (value: string) => {
|
|
setSearchTerm(value);
|
|
setCurrentPage(1); // 검색 시 첫 페이지로 이동
|
|
};
|
|
|
|
// 매핑 정보 요약 생성
|
|
const getMappingSummary = (mappings: BatchMapping[]) => {
|
|
if (!mappings || mappings.length === 0) {
|
|
return "매핑 없음";
|
|
}
|
|
|
|
const tableGroups = new Map<string, number>();
|
|
mappings.forEach(mapping => {
|
|
const key = `${mapping.from_table_name} → ${mapping.to_table_name}`;
|
|
tableGroups.set(key, (tableGroups.get(key) || 0) + 1);
|
|
});
|
|
|
|
const summaries = Array.from(tableGroups.entries()).map(([key, count]) =>
|
|
`${key} (${count}개 컬럼)`
|
|
);
|
|
|
|
return summaries.join(", ");
|
|
};
|
|
|
|
return (
|
|
<div className="container mx-auto p-6 space-y-6">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">배치 관리</h1>
|
|
<p className="text-muted-foreground">데이터베이스 간 배치 작업을 관리합니다.</p>
|
|
</div>
|
|
<Button
|
|
onClick={() => router.push("/admin/batchmng/create")}
|
|
className="flex items-center space-x-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
<span>배치 추가</span>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 검색 및 필터 */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center space-x-4">
|
|
<div className="flex-1 relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
|
<Input
|
|
placeholder="배치명 또는 설명으로 검색..."
|
|
value={searchTerm}
|
|
onChange={(e) => handleSearch(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
onClick={loadBatchConfigs}
|
|
disabled={loading}
|
|
className="flex items-center space-x-2"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
|
<span>새로고침</span>
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 배치 목록 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center justify-between">
|
|
<span>배치 목록 ({batchConfigs.length}개)</span>
|
|
{loading && <RefreshCw className="h-4 w-4 animate-spin" />}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{batchConfigs.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
<h3 className="text-lg font-semibold mb-2">배치가 없습니다</h3>
|
|
<p className="text-muted-foreground mb-4">
|
|
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
|
|
</p>
|
|
{!searchTerm && (
|
|
<Button
|
|
onClick={() => router.push("/admin/batchmng/create")}
|
|
className="flex items-center space-x-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
<span>첫 번째 배치 추가</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{batchConfigs.map((batch) => (
|
|
<div key={batch.id} className="border rounded-lg p-6 space-y-4">
|
|
{/* 배치 기본 정보 */}
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center space-x-3">
|
|
<h3 className="text-lg font-semibold">{batch.batch_name}</h3>
|
|
<Badge variant={batch.is_active === 'Y' ? 'default' : 'secondary'}>
|
|
{batch.is_active === 'Y' ? '활성' : '비활성'}
|
|
</Badge>
|
|
</div>
|
|
{batch.description && (
|
|
<p className="text-muted-foreground">{batch.description}</p>
|
|
)}
|
|
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
|
<div className="flex items-center space-x-1">
|
|
<Clock className="h-4 w-4" />
|
|
<span>{batch.cron_schedule}</span>
|
|
</div>
|
|
<div>
|
|
생성일: {new Date(batch.created_date).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 액션 버튼들 */}
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => executeBatch(batch.id)}
|
|
disabled={executingBatch === batch.id}
|
|
className="flex items-center space-x-1"
|
|
>
|
|
{executingBatch === batch.id ? (
|
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Play className="h-4 w-4" />
|
|
)}
|
|
<span>실행</span>
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => toggleBatchStatus(batch.id, batch.is_active)}
|
|
className="flex items-center space-x-1"
|
|
>
|
|
{batch.is_active === 'Y' ? (
|
|
<Pause className="h-4 w-4" />
|
|
) : (
|
|
<Play className="h-4 w-4" />
|
|
)}
|
|
<span>{batch.is_active === 'Y' ? '비활성화' : '활성화'}</span>
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => router.push(`/admin/batchmng/edit/${batch.id}`)}
|
|
className="flex items-center space-x-1"
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
<span>수정</span>
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => deleteBatch(batch.id, batch.batch_name)}
|
|
className="flex items-center space-x-1 text-red-600 hover:text-red-700"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
<span>삭제</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 매핑 정보 */}
|
|
{batch.batch_mappings && batch.batch_mappings.length > 0 && (
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-medium text-muted-foreground">
|
|
매핑 정보 ({batch.batch_mappings.length}개)
|
|
</h4>
|
|
<div className="text-sm">
|
|
{getMappingSummary(batch.batch_mappings)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 페이지네이션 */}
|
|
{totalPages > 1 && (
|
|
<div className="flex justify-center space-x-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
|
disabled={currentPage === 1}
|
|
>
|
|
이전
|
|
</Button>
|
|
|
|
<div className="flex items-center space-x-1">
|
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
|
const pageNum = i + 1;
|
|
return (
|
|
<Button
|
|
key={pageNum}
|
|
variant={currentPage === pageNum ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setCurrentPage(pageNum)}
|
|
>
|
|
{pageNum}
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
|
disabled={currentPage === totalPages}
|
|
>
|
|
다음
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |