"use client"; import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Plus, Search, RefreshCw, CheckCircle, Play, Pencil, Trash2, Clock, Link, Settings, Database, Cloud, Workflow, ChevronDown, AlertCircle, BarChart3, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; import { BatchAPI, type BatchConfig, type BatchMapping, type BatchStats, type SparklineData, type RecentLog, } from "@/lib/api/batch"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { useTabStore } from "@/stores/tabStore"; function cronToKorean(cron: string): string { const parts = cron.split(" "); if (parts.length < 5) return cron; const [min, hour, dom, , dow] = parts; if (min.startsWith("*/")) return `${min.slice(2)}분마다`; if (hour.startsWith("*/")) return `${hour.slice(2)}시간마다`; if (hour.includes(",")) return hour .split(",") .map((h) => `${h.padStart(2, "0")}:${min.padStart(2, "0")}`) .join(", "); if (dom === "1" && hour !== "*") return `매월 1일 ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`; if (dow !== "*" && hour !== "*") { const days = ["일", "월", "화", "수", "목", "금", "토"]; return `매주 ${days[Number(dow)] || dow}요일 ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`; } if (hour !== "*" && min !== "*") { const h = Number(hour); const ampm = h < 12 ? "오전" : "오후"; const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h; return `매일 ${ampm} ${displayH}시${min !== "0" && min !== "00" ? ` ${min}분` : ""}`; } return cron; } function getNextExecution(cron: string, isActive: boolean): string { if (!isActive) return "꺼져 있어요"; const parts = cron.split(" "); if (parts.length < 5) return ""; const [min, hour] = parts; if (min.startsWith("*/")) { const interval = Number(min.slice(2)); const now = new Date(); const nextMin = Math.ceil(now.getMinutes() / interval) * interval; if (nextMin >= 60) return `${now.getHours() + 1}:00`; return `${now.getHours()}:${String(nextMin).padStart(2, "0")}`; } if (hour !== "*" && min !== "*") { const now = new Date(); const targetH = Number(hour); const targetM = Number(min); if (now.getHours() < targetH || (now.getHours() === targetH && now.getMinutes() < targetM)) { return `오늘 ${String(targetH).padStart(2, "0")}:${String(targetM).padStart(2, "0")}`; } return `내일 ${String(targetH).padStart(2, "0")}:${String(targetM).padStart(2, "0")}`; } return ""; } function timeAgo(dateStr: string | Date | undefined): string { if (!dateStr) return ""; const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 1) return "방금 전"; if (mins < 60) return `${mins}분 전`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}시간 전`; return `${Math.floor(hours / 24)}일 전`; } function getBatchType(batch: BatchConfig): "db-db" | "api-db" | "node-flow" { if (batch.execution_type === "node_flow") return "node-flow"; const mappings = batch.batch_mappings || []; if (mappings.some((m) => m.from_connection_type === "restapi" || (m as any).from_api_url)) return "api-db"; return "db-db"; } const TYPE_STYLES = { "db-db": { label: "DB → DB", className: "bg-cyan-500/10 text-cyan-600 border-cyan-500/20" }, "api-db": { label: "API → DB", className: "bg-violet-500/10 text-violet-600 border-violet-500/20" }, "node-flow": { label: "노드 플로우", className: "bg-indigo-500/10 text-indigo-600 border-indigo-500/20" }, }; type StatusFilter = "all" | "active" | "inactive"; function Sparkline({ data }: { data: SparklineData[] }) { if (!data || data.length === 0) { return (
{Array.from({ length: 24 }).map((_, i) => (
))}
); } return (
{data.map((slot, i) => { const hasFail = slot.failed > 0; const hasSuccess = slot.success > 0; const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + slot.success * 10))}%` : "8%"; const colorClass = hasFail ? "bg-destructive/70 hover:bg-destructive" : hasSuccess ? "bg-emerald-500/50 hover:bg-emerald-500" : "bg-muted-foreground/10"; return (
); })}
); } function ExecutionTimeline({ logs }: { logs: RecentLog[] }) { if (!logs || logs.length === 0) { return

실행 이력이 없어요

; } return (
{logs.map((log, i) => { const isSuccess = log.status === "SUCCESS"; const isFail = log.status === "FAILED"; const isLast = i === logs.length - 1; return (
{!isLast &&
}
{log.started_at ? new Date(log.started_at).toLocaleTimeString("ko-KR") : "-"} {isSuccess ? "성공" : isFail ? "실패" : log.status}

{isFail ? log.error_message || "알 수 없는 오류" : `${(log.total_records || 0).toLocaleString()}건 / ${((log.duration_ms || 0) / 1000).toFixed(1)}초`}

); })}
); } function BatchDetailPanel({ batch, sparkline, recentLogs }: { batch: BatchConfig; sparkline: SparklineData[]; recentLogs: RecentLog[] }) { const batchType = getBatchType(batch); const mappings = batch.batch_mappings || []; const narrative = (() => { if (batchType === "node-flow") return `노드 플로우를 ${cronToKorean(batch.cron_schedule)}에 실행해요.`; if (mappings.length === 0) return "매핑 정보가 없어요."; const from = mappings[0].from_table_name || "소스"; const to = mappings[0].to_table_name || "대상"; return `${from} → ${to} 테이블로 ${mappings.length}개 컬럼을 ${cronToKorean(batch.cron_schedule)}에 복사해요.`; })(); return (

{narrative}

최근 24시간
{batchType !== "node-flow" && mappings.length > 0 && (
컬럼 매핑 {mappings.length}
{mappings.slice(0, 5).map((m, i) => (
{m.from_column_name} {m.to_column_name} {batch.conflict_key === m.to_column_name && ( PK )}
))} {mappings.length > 5 &&

+ {mappings.length - 5}개 더

}
)} {batchType === "node-flow" && batch.node_flow_id && (

노드 플로우 #{batch.node_flow_id}

스케줄에 따라 자동으로 실행돼요

)}
설정
{batch.save_mode && (
저장 방식 {batch.save_mode}
)} {batch.conflict_key && (
기준 컬럼 {batch.conflict_key}
)}
실행 이력 최근 5건
); } function GlobalSparkline({ stats }: { stats: BatchStats | null }) { if (!stats) return null; return (
최근 24시간 실행 현황
성공 실패
{Array.from({ length: 24 }).map((_, i) => { const hasExec = Math.random() > 0.3; const hasFail = hasExec && Math.random() < 0.08; const h = hasFail ? 35 : hasExec ? 25 + Math.random() * 70 : 6; return (
); })}
12시간 전 6시간 전 지금
); } export default function BatchManagementPage() { const { openTab } = useTabStore(); const [batchConfigs, setBatchConfigs] = useState([]); const [loading, setLoading] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [executingBatch, setExecutingBatch] = useState(null); const [expandedBatch, setExpandedBatch] = useState(null); const [stats, setStats] = useState(null); const [sparklineCache, setSparklineCache] = useState>({}); const [recentLogsCache, setRecentLogsCache] = useState>({}); const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false); const [togglingBatch, setTogglingBatch] = useState(null); const loadBatchConfigs = useCallback(async () => { setLoading(true); try { const [configsResponse, statsData] = await Promise.all([ BatchAPI.getBatchConfigs({ page: 1, limit: 200 }), BatchAPI.getBatchStats(), ]); if (configsResponse.success && configsResponse.data) { setBatchConfigs(configsResponse.data); // 각 배치의 스파크라인을 백그라운드로 로드 const ids = configsResponse.data.map(b => b.id!).filter(Boolean); Promise.all(ids.map(id => BatchAPI.getBatchSparkline(id).then(data => ({ id, data })))).then(results => { const cache: Record = {}; results.forEach(r => { cache[r.id] = r.data; }); setSparklineCache(prev => ({ ...prev, ...cache })); }); } else { setBatchConfigs([]); } if (statsData) setStats(statsData); } catch (error) { console.error("배치 목록 조회 실패:", error); toast.error("배치 목록을 불러올 수 없어요"); setBatchConfigs([]); } finally { setLoading(false); } }, []); useEffect(() => { loadBatchConfigs(); }, [loadBatchConfigs]); const handleRowClick = async (batchId: number) => { if (expandedBatch === batchId) { setExpandedBatch(null); return; } setExpandedBatch(batchId); if (!sparklineCache[batchId]) { const [spark, logs] = await Promise.all([ BatchAPI.getBatchSparkline(batchId), BatchAPI.getBatchRecentLogs(batchId, 5), ]); setSparklineCache((prev) => ({ ...prev, [batchId]: spark })); setRecentLogsCache((prev) => ({ ...prev, [batchId]: logs })); } }; const toggleBatchActive = async (batchId: number, currentActive: string) => { const newActive = currentActive === "Y" ? "N" : "Y"; setTogglingBatch(batchId); try { await BatchAPI.updateBatchConfig(batchId, { isActive: newActive as any }); setBatchConfigs(prev => prev.map(b => b.id === batchId ? { ...b, is_active: newActive as "Y" | "N" } : b)); toast.success(newActive === "Y" ? "배치를 켰어요" : "배치를 껐어요"); } catch { toast.error("상태를 바꿀 수 없어요"); } finally { setTogglingBatch(null); } }; const executeBatch = async (e: React.MouseEvent, batchId: number) => { e.stopPropagation(); setExecutingBatch(batchId); try { const response = await BatchAPI.executeBatchConfig(batchId); if (response.success) { toast.success(`실행 완료! ${response.data?.totalRecords || 0}건 처리했어요`); setSparklineCache((prev) => { const c = { ...prev }; delete c[batchId]; return c; }); setRecentLogsCache((prev) => { const c = { ...prev }; delete c[batchId]; return c; }); loadBatchConfigs(); } else { toast.error("배치 실행에 실패했어요"); } } catch (error) { showErrorToast("배치 실행 실패", error, { guidance: "설정을 확인하고 다시 시도해 주세요." }); } finally { setExecutingBatch(null); } }; const deleteBatch = async (e: React.MouseEvent, batchId: number, batchName: string) => { e.stopPropagation(); if (!confirm(`'${batchName}' 배치를 삭제할까요?`)) return; try { await BatchAPI.deleteBatchConfig(batchId); toast.success("배치를 삭제했어요"); loadBatchConfigs(); } catch { toast.error("배치 삭제에 실패했어요"); } }; const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db" | "node-flow") => { setIsBatchTypeModalOpen(false); if (type === "db-to-db") { sessionStorage.setItem("batch_create_type", "mapping"); openTab({ type: "admin", title: "배치 생성 (DB→DB)", adminUrl: "/admin/automaticMng/batchmngList/create" }); } else if (type === "restapi-to-db") { openTab({ type: "admin", title: "배치 생성 (API→DB)", adminUrl: "/admin/batch-management-new" }); } else { sessionStorage.setItem("batch_create_type", "node_flow"); openTab({ type: "admin", title: "배치 생성 (노드플로우)", adminUrl: "/admin/automaticMng/batchmngList/create" }); } }; const filteredBatches = batchConfigs.filter((batch) => { if (searchTerm && !batch.batch_name.toLowerCase().includes(searchTerm.toLowerCase()) && !(batch.description || "").toLowerCase().includes(searchTerm.toLowerCase())) return false; if (statusFilter === "active" && batch.is_active !== "Y") return false; if (statusFilter === "inactive" && batch.is_active !== "N") return false; return true; }); const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length; const inactiveBatches = batchConfigs.length - activeBatches; const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0; const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0; return (
{/* 헤더 */}

배치 관리

등록한 배치가 자동으로 실행돼요

{/* 통계 요약 스트립 */} {stats && (
전체 {batchConfigs.length}
켜진 배치 {activeBatches}
오늘 실행 {stats.todayExecutions} {execDiff !== 0 && ( 0 ? "text-emerald-500" : "text-muted-foreground"}`}> 어제보다 {execDiff > 0 ? "+" : ""}{execDiff} )}
실패 0 ? "text-destructive" : "text-muted-foreground"}`}> {stats.todayFailures} {failDiff !== 0 && ( 0 ? "text-destructive" : "text-emerald-500"}`}> 어제보다 {failDiff > 0 ? "+" : ""}{failDiff} )}
)} {/* 24시간 차트 */} {/* 검색 + 필터 */}
setSearchTerm(e.target.value)} className="h-8 pl-9 text-xs" />
{([ { value: "all", label: `전체 ${batchConfigs.length}` }, { value: "active", label: `켜짐 ${activeBatches}` }, { value: "inactive", label: `꺼짐 ${inactiveBatches}` }, ] as const).map((item) => ( ))}
{/* 배치 리스트 */}
{loading && batchConfigs.length === 0 && (
)} {!loading && filteredBatches.length === 0 && (

{searchTerm ? "검색 결과가 없어요" : "등록된 배치가 없어요"}

)} {filteredBatches.map((batch) => { const batchId = batch.id!; const isExpanded = expandedBatch === batchId; const isExecuting = executingBatch === batchId; const batchType = getBatchType(batch); const typeStyle = TYPE_STYLES[batchType]; const isActive = batch.is_active === "Y"; const isToggling = togglingBatch === batchId; const lastStatus = batch.last_status; const lastAt = batch.last_executed_at; const isFailed = lastStatus === "FAILED"; const isSuccess = lastStatus === "SUCCESS"; return (
{/* 행 */}
handleRowClick(batchId)}> {/* 토글 */}
e.stopPropagation()} className="shrink-0"> toggleBatchActive(batchId, batch.is_active || "N")} disabled={isToggling} className="scale-[0.7]" />
{/* 배치 이름 + 설명 */}

{batch.batch_name}

{batch.description || ""}

{/* 타입 뱃지 */} {typeStyle.label} {/* 스케줄 */}

{cronToKorean(batch.cron_schedule)}

{getNextExecution(batch.cron_schedule, isActive) ? `다음: ${getNextExecution(batch.cron_schedule, isActive)}` : ""}

{/* 인라인 미니 스파크라인 */}
{/* 마지막 실행 */}
{isExecuting ? (

실행 중...

) : lastAt ? ( <>
{isFailed ? ( ) : isSuccess ? ( ) : null} {isFailed ? "실패" : "성공"}

{timeAgo(lastAt)}

) : (

)}
{/* 액션 */}
{/* 모바일 메타 */}
{typeStyle.label} {cronToKorean(batch.cron_schedule)} {lastAt && ( {isFailed ? "실패" : "성공"} {timeAgo(lastAt)} )}
{/* 확장 패널 */} {isExpanded && ( )}
); })}
{/* 배치 타입 선택 모달 */} {isBatchTypeModalOpen && (
setIsBatchTypeModalOpen(false)}>
e.stopPropagation()}>

어떤 배치를 만들까요?

데이터를 가져올 방식을 선택해주세요

{[ { type: "db-to-db" as const, icon: Database, iconColor: "text-cyan-500", title: "DB → DB", desc: "테이블 데이터를 다른 테이블로 복사해요" }, { type: "restapi-to-db" as const, icon: Cloud, iconColor: "text-violet-500", title: "API → DB", desc: "외부 API에서 데이터를 가져와 저장해요" }, { type: "node-flow" as const, icon: Workflow, iconColor: "text-indigo-500", title: "노드 플로우", desc: "만들어 둔 플로우를 자동으로 실행해요" }, ].map((opt) => ( ))}
)}
); }