diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx index 64e725e8..a384b645 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx @@ -1,102 +1,57 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { - Plus, - Search, +import { + Plus, + Search, RefreshCw, - Database, - LayoutGrid, - CheckCircle, - Activity, - AlertTriangle, + Database } from "lucide-react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; import { useRouter } from "next/navigation"; -import { BatchAPI, type BatchConfig } from "@/lib/api/batch"; -import apiClient from "@/lib/api/client"; -import { ScrollToTop } from "@/components/common/ScrollToTop"; -import { cn } from "@/lib/utils"; +import { + BatchAPI, + BatchConfig, + BatchMapping, +} from "@/lib/api/batch"; import BatchCard from "@/components/admin/BatchCard"; - -// 대시보드 통계 타입 (백엔드 GET /batch-management/stats) -interface BatchStats { - totalBatches: number; - activeBatches: number; - todayExecutions: number; - todayFailures: number; - prevDayExecutions?: number; - prevDayFailures?: number; -} - -// 스파크라인/최근 로그 타입은 BatchCard 내부 정의 사용 - -/** Cron 표현식 → 한글 설명 */ -function cronToKorean(cron: string): string { - if (!cron || !cron.trim()) return "-"; - const parts = cron.trim().split(/\s+/); - if (parts.length < 5) return cron; - const [min, hour, dayOfMonth, month, dayOfWeek] = parts; - - // 매 30분: */30 * * * * - if (min.startsWith("*/") && hour === "*" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") { - const n = min.slice(2); - return `매 ${n}분`; - } - // 매 N시간: 0 */2 * * * - if (min === "0" && hour.startsWith("*/") && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") { - const n = hour.slice(2); - return `매 ${n}시간`; - } - // 매일 HH:MM: 0 1 * * * 또는 0 6,18 * * * - if (min === "0" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") { - if (hour.includes(",")) { - const times = hour.split(",").map((h) => `${h.padStart(2, "0")}:00`); - return times.join(", "); - } - return `매일 ${hour.padStart(2, "0")}:00`; - } - // 매주 일 03:00: 0 3 * * 0 - if (min === "0" && dayOfMonth === "*" && month === "*" && dayOfWeek !== "*" && dayOfWeek !== "?") { - const dayNames = ["일", "월", "화", "수", "목", "금", "토"]; - const d = dayNames[parseInt(dayOfWeek, 10)] ?? dayOfWeek; - return `매주 ${d} ${hour.padStart(2, "0")}:00`; - } - // 매월 1일 00:00: 0 0 1 * * - if (min === "0" && hour === "0" && dayOfMonth !== "*" && month === "*" && dayOfWeek === "*") { - return `매월 ${dayOfMonth}일 00:00`; - } - return cron; -} - -type StatusFilter = "all" | "active" | "inactive"; -type TypeFilter = "all" | "mapping" | "restapi" | "node_flow"; +import { ScrollToTop } from "@/components/common/ScrollToTop"; export default function BatchManagementPage() { const router = useRouter(); + + // 상태 관리 const [batchConfigs, setBatchConfigs] = useState([]); const [loading, setLoading] = useState(false); - const [stats, setStats] = useState(null); - const [statsLoading, setStatsLoading] = useState(false); const [searchTerm, setSearchTerm] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [typeFilter, setTypeFilter] = useState("all"); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); const [executingBatch, setExecutingBatch] = useState(null); - const [expandedId, setExpandedId] = useState(null); const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false); - const loadBatchConfigs = useCallback(async () => { + // 페이지 로드 시 배치 목록 조회 + useEffect(() => { + loadBatchConfigs(); + }, [currentPage, searchTerm]); + + // 배치 설정 목록 조회 + const loadBatchConfigs = async () => { setLoading(true); try { const response = await BatchAPI.getBatchConfigs({ - limit: 500, + page: currentPage, + limit: 10, search: searchTerm || undefined, }); + if (response.success && response.data) { setBatchConfigs(response.data); + if (response.pagination) { + setTotalPages(response.pagination.totalPages); + } } else { setBatchConfigs([]); } @@ -107,46 +62,20 @@ export default function BatchManagementPage() { } finally { setLoading(false); } - }, [searchTerm]); - - const loadStats = useCallback(async () => { - setStatsLoading(true); - try { - const res = await apiClient.get<{ success: boolean; data?: BatchStats }>("/batch-management/stats"); - if (res.data?.success && res.data.data) { - setStats(res.data.data); - } else { - setStats(null); - } - } catch { - setStats(null); - } finally { - setStatsLoading(false); - } - }, []); - - useEffect(() => { - loadBatchConfigs(); - }, [loadBatchConfigs]); - - useEffect(() => { - loadStats(); - }, [loadStats]); + }; + // 배치 수동 실행 const executeBatch = async (batchId: number) => { setExecutingBatch(batchId); try { const response = await BatchAPI.executeBatchConfig(batchId); if (response.success) { - toast.success( - `배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords ?? 0}개, 성공: ${response.data?.successRecords ?? 0}개)` - ); - loadBatchConfigs(); - loadStats(); + toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`); } else { toast.error("배치 실행에 실패했습니다."); } } catch (error) { + console.error("배치 실행 실패:", error); showErrorToast("배치 실행에 실패했습니다", error, { guidance: "배치 설정을 확인하고 다시 시도해 주세요.", }); @@ -155,230 +84,226 @@ export default function BatchManagementPage() { } }; + // 배치 활성화/비활성화 토글 const toggleBatchStatus = async (batchId: number, currentStatus: string) => { + console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus }); + try { - const newStatus = currentStatus === "Y" ? "N" : "Y"; - await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus === "Y" }); - toast.success(`배치가 ${newStatus === "Y" ? "활성화" : "비활성화"}되었습니다.`); - loadBatchConfigs(); - loadStats(); + const newStatus = currentStatus === 'Y' ? 'N' : 'Y'; + console.log("📝 새로운 상태:", newStatus); + + const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus }); + console.log("✅ API 호출 성공:", result); + + toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`); + loadBatchConfigs(); // 목록 새로고침 } catch (error) { - console.error("배치 상태 변경 실패:", error); + console.error("❌ 배치 상태 변경 실패:", error); toast.error("배치 상태 변경에 실패했습니다."); } }; + // 배치 삭제 const deleteBatch = async (batchId: number, batchName: string) => { - if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) return; + if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) { + return; + } + try { await BatchAPI.deleteBatchConfig(batchId); toast.success("배치가 삭제되었습니다."); - loadBatchConfigs(); - loadStats(); + loadBatchConfigs(); // 목록 새로고침 } catch (error) { console.error("배치 삭제 실패:", error); toast.error("배치 삭제에 실패했습니다."); } }; - const handleCreateBatch = () => setIsBatchTypeModalOpen(true); - - const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db") => { - setIsBatchTypeModalOpen(false); - if (type === "db-to-db") { - router.push("/admin/batchmng/create"); - } else { - router.push("/admin/batch-management-new"); - } + // 검색 처리 + const handleSearch = (value: string) => { + setSearchTerm(value); + setCurrentPage(1); // 검색 시 첫 페이지로 이동 }; - const filteredBatches = useMemo(() => { - let list = batchConfigs; - if (statusFilter === "active") list = list.filter((b) => b.is_active === "Y"); - else if (statusFilter === "inactive") list = list.filter((b) => b.is_active !== "Y"); - const et = (b: BatchConfig) => (b as { execution_type?: string }).execution_type; - if (typeFilter === "mapping") list = list.filter((b) => !et(b) || et(b) === "mapping"); - else if (typeFilter === "restapi") list = list.filter((b) => et(b) === "restapi"); - else if (typeFilter === "node_flow") list = list.filter((b) => et(b) === "node_flow"); - return list; - }, [batchConfigs, statusFilter, typeFilter]); + // 매핑 정보 요약 생성 + const getMappingSummary = (mappings: BatchMapping[]) => { + if (!mappings || mappings.length === 0) { + return "매핑 없음"; + } + + const tableGroups = new Map(); + 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(", "); + }; + + // 배치 추가 버튼 클릭 핸들러 + const handleCreateBatch = () => { + setIsBatchTypeModalOpen(true); + }; + + // 배치 타입 선택 핸들러 + const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => { + console.log("배치 타입 선택:", type); + setIsBatchTypeModalOpen(false); + + if (type === 'db-to-db') { + // 기존 DB → DB 배치 생성 페이지로 이동 + console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create'); + router.push('/admin/batchmng/create'); + } else if (type === 'restapi-to-db') { + // 새로운 REST API 배치 페이지로 이동 + console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new'); + try { + router.push('/admin/batch-management-new'); + console.log("라우터 push 실행 완료"); + } catch (error) { + console.error("라우터 push 오류:", error); + // 대안: window.location 사용 + window.location.href = '/admin/batch-management-new'; + } + } + }; return (
- {/* 헤더 */} -
-
-

배치 관리

-

- 데이터 동기화 배치 작업을 모니터링하고 관리합니다. -

-
-
- -
+ + {/* 액션 버튼 영역 */} +
+
+ 총{" "} + + {batchConfigs.length.toLocaleString()} + {" "} + 건 +
+
- {/* 통계 카드 4개 */} -
-
-
-

전체 배치

-

- {statsLoading ? "-" : (stats?.totalBatches ?? 0).toLocaleString()} -

-
-
- -
-
-
-
-

활성 배치

-

- {statsLoading ? "-" : (stats?.activeBatches ?? 0).toLocaleString()} -

-
-
- -
-
-
-
-

오늘 실행

-

- {statsLoading ? "-" : (stats?.todayExecutions ?? 0).toLocaleString()} -

-
-
- -
-
-
-
-

오늘 실패

-

- {statsLoading ? "-" : (stats?.todayFailures ?? 0).toLocaleString()} -

-
-
- -
-
-
- - {/* 툴바 */} -
-
- - setSearchTerm(e.target.value)} - className="h-9 rounded-lg border bg-card pl-9 text-xs" - /> -
-
-
- {(["all", "active", "inactive"] as const).map((s) => ( - - ))} + + 첫 번째 배치 추가 + + )}
-
- {(["all", "mapping", "restapi", "node_flow"] as const).map((t) => ( - - ))} -
-
-
- 총 {filteredBatches.length}건 -
-
- - {/* 배치 테이블 */} - {filteredBatches.length === 0 ? ( -
- -

- {searchTerm || statusFilter !== "all" || typeFilter !== "all" ? "검색 결과가 없습니다." : "배치가 없습니다."} -

- {!searchTerm && statusFilter === "all" && typeFilter === "all" && ( - - )}
) : ( -
- - - - - - - - - - - - - {filteredBatches.map((batch) => ( - setExpandedId((id) => (id === batch.id ? null : batch.id ?? null))} - executingBatch={executingBatch} - onExecute={executeBatch} - onToggleStatus={toggleBatchStatus} - onEdit={(id) => router.push(`/admin/batchmng/edit/${id}`)} - onDelete={deleteBatch} - cronToKorean={cronToKorean} - /> - ))} - -
- - 배치 - - 타입 - - 스케줄 - - 최근 24h - - 마지막 실행 - - 액션 -
+
+ {batchConfigs.map((batch) => ( + { + toggleBatchStatus(batchId, currentStatus); + }} + onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)} + onDelete={deleteBatch} + getMappingSummary={getMappingSummary} + /> + ))} +
+ )} + + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+ + +
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const pageNum = i + 1; + return ( + + ); + })} +
+ +
)} @@ -386,49 +311,60 @@ export default function BatchManagementPage() { {isBatchTypeModalOpen && (
-

배치 타입 선택

-
- - -
-
- +
+

배치 타입 선택

+ +
+ {/* DB → DB */} + + + {/* REST API → DB */} + +
+ +
+ +
)}
+ + {/* Scroll to Top 버튼 */}
); -} +} \ No newline at end of file diff --git a/frontend/components/admin/BatchCard.tsx b/frontend/components/admin/BatchCard.tsx index 13cf9927..374c81a2 100644 --- a/frontend/components/admin/BatchCard.tsx +++ b/frontend/components/admin/BatchCard.tsx @@ -1,377 +1,173 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React from "react"; +import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { Play, Pencil, Trash2, ChevronDown, ChevronRight, Clock } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + Play, + Pause, + Edit, + Trash2, + RefreshCw, + Clock, + Database, + Calendar, + Activity, + Settings +} from "lucide-react"; import { BatchConfig } from "@/lib/api/batch"; -import apiClient from "@/lib/api/client"; -import { cn } from "@/lib/utils"; - -interface SparklineSlot { - hour: string; - success: number; - failed: number; -} - -interface BatchRecentLog { - id?: number; - started_at?: string; - finished_at?: string; - status?: string; - total_records?: number; - success_records?: number; - failed_records?: number; - error_message?: string | null; - duration_ms?: number; -} - -type LedStatus = "on" | "run" | "off" | "err"; - -function BatchLED({ status }: { status: LedStatus }) { - return ( -
- ); -} - -/** 스파크라인 24바 (div 기반, 높이 24px) */ -function SparklineBars({ slots }: { slots: SparklineSlot[] }) { - if (!slots || slots.length === 0) { - return ( -
- {Array.from({ length: 24 }).map((_, i) => ( -
- ))} -
- ); - } - return ( -
- {slots.slice(0, 24).map((slot, i) => { - const hasRun = slot.success + slot.failed > 0; - const isFail = slot.failed > 0; - const height = - !hasRun ? 5 : isFail ? Math.min(40, 20 + slot.failed * 5) : Math.max(80, Math.min(95, 80 + slot.success)); - return ( -
- ); - })} -
- ); -} interface BatchCardProps { batch: BatchConfig; - expanded: boolean; - onToggleExpand: () => void; executingBatch: number | null; onExecute: (batchId: number) => void; onToggleStatus: (batchId: number, currentStatus: string) => void; onEdit: (batchId: number) => void; onDelete: (batchId: number, batchName: string) => void; - cronToKorean: (cron: string) => string; + getMappingSummary: (mappings: any[]) => string; } export default function BatchCard({ batch, - expanded, - onToggleExpand, executingBatch, onExecute, onToggleStatus, onEdit, onDelete, - cronToKorean, + getMappingSummary }: BatchCardProps) { - const [sparkline, setSparkline] = useState([]); - const [recentLogs, setRecentLogs] = useState([]); - const [detailLoading, setDetailLoading] = useState(false); - + // 상태에 따른 스타일 결정 const isExecuting = executingBatch === batch.id; - const isActive = batch.is_active === "Y"; - const ledStatus: LedStatus = isExecuting ? "run" : isActive ? "on" : "off"; - - const executionType = (batch as { execution_type?: string }).execution_type; - const typeLabel = - executionType === "node_flow" - ? "노드 플로우" - : executionType === "restapi" || (batch.batch_mappings?.some((m) => m.from_connection_type === "external")) - ? "API→DB" - : "DB→DB"; - const typeBadgeClass = - executionType === "node_flow" - ? "bg-indigo-500/10 text-indigo-600" - : executionType === "restapi" || batch.batch_mappings?.some((m) => m.from_connection_type === "external") - ? "bg-violet-500/10 text-violet-600" - : "bg-cyan-500/10 text-cyan-600"; - - const loadDetail = useCallback(async () => { - if (!batch.id) return; - setDetailLoading(true); - try { - const [sparkRes, logsRes] = await Promise.all([ - apiClient.get<{ success: boolean; data?: SparklineSlot[] }>( - `/batch-management/batch-configs/${batch.id}/sparkline` - ), - apiClient.get<{ success: boolean; data?: BatchRecentLog[] }>( - `/batch-management/batch-configs/${batch.id}/recent-logs?limit=5` - ), - ]); - if (sparkRes.data?.success && Array.isArray(sparkRes.data.data)) { - setSparkline(sparkRes.data.data); - } - if (logsRes.data?.success && Array.isArray(logsRes.data.data)) { - setRecentLogs(logsRes.data.data); - } - } catch { - setSparkline([]); - setRecentLogs([]); - } finally { - setDetailLoading(false); - } - }, [batch.id]); - - useEffect(() => { - if (expanded) loadDetail(); - }, [expanded, loadDetail]); - - const lastLog = recentLogs[0]; - const lastRunText = isExecuting - ? "실행 중..." - : !isActive - ? "-" - : lastLog?.started_at - ? new Date(lastLog.started_at).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", second: "2-digit" }) - : "-"; - const lastRunSub = - !isActive || !lastLog?.started_at - ? "비활성" - : isExecuting - ? "" - : (() => { - const min = Math.floor((Date.now() - new Date(lastLog.started_at).getTime()) / 60000); - if (min < 1) return "방금 전"; - if (min < 60) return `${min}분 전`; - return `${Math.floor(min / 60)}시간 전`; - })(); - - const handleRowClick = (e: React.MouseEvent) => { - if ((e.target as HTMLElement).closest("button")) return; - onToggleExpand(); - }; + const isActive = batch.is_active === 'Y'; return ( - <> - (e.key === "Enter" || e.key === " ") && onToggleExpand()} - className={cn( - "min-h-[60px] border-b transition-colors hover:bg-card/80", - expanded && "bg-primary/5 shadow-[inset_3px_0_0_0_hsl(var(--primary))]" - )} - > - -
- {expanded ? ( - - ) : ( - - )} - -
- - -
-

- {batch.batch_name} + + + {/* 헤더 */} +

+
+
+ +

{batch.batch_name}

+
+

+ {batch.description || '설명 없음'}

-

{batch.description || "설명 없음"}

- - - - {typeLabel} - - - -

- {batch.cron_schedule} -

-

{cronToKorean(batch.cron_schedule)}

- - - {expanded ? ( - - ) : ( -
- {Array.from({ length: 24 }).map((_, i) => ( -
- ))} + + {isExecuting ? '실행 중' : isActive ? '활성' : '비활성'} + +
+ + {/* 정보 */} +
+ {/* 스케줄 정보 */} +
+ + + 스케줄 + + {batch.cron_schedule} +
+ + {/* 생성일 정보 */} +
+ + + 생성일 + + + {new Date(batch.created_date).toLocaleDateString('ko-KR')} + +
+ + {/* 매핑 정보 */} + {batch.batch_mappings && batch.batch_mappings.length > 0 && ( +
+ + + 매핑 + + + {batch.batch_mappings.length}개 +
)} - - -

{lastRunText}

-

{lastRunSub}

- - -
e.stopPropagation()}> - - - -
- - - {expanded && ( - - -
- {detailLoading ? ( -
로딩 중...
- ) : ( -
-
-

- {batch.description || batch.batch_name} 배치입니다. 스케줄: {cronToKorean(batch.cron_schedule)} - {recentLogs.length > 0 && - ` · 최근 실행 ${recentLogs.length}건`} -

-
-
-
-

- - 최근 24시간 -

-
- -
-
-
-

- - 실행 이력 (최근 5건) -

-
- - - - - - - - - - - {recentLogs.length === 0 ? ( - - - - ) : ( - recentLogs.map((log, i) => ( - - - - - - - )) - )} - -
시간상태처리에러
- 이력 없음 -
- {log.started_at - ? new Date(log.started_at).toLocaleString("ko-KR", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }) - : "-"} - - - {log.status === "SUCCESS" || log.status === "success" ? "성공" : "실패"} - - - {log.success_records ?? log.total_records ?? 0}건 - {log.duration_ms != null ? ` / ${(log.duration_ms / 1000).toFixed(1)}s` : ""} - - {log.error_message || "-"} -
-
-
-
-
- )} +
+ + {/* 실행 중 프로그레스 */} + {isExecuting && ( +
+
+ + 실행 중...
- - - )} - +
+
+
+
+ )} + + {/* 액션 버튼 */} +
+ {/* 실행 버튼 */} + + + {/* 활성화/비활성화 버튼 */} + + + {/* 수정 버튼 */} + + + {/* 삭제 버튼 */} + +
+ + ); }