"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 (
{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}
{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}
)}
);
}
function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
if (!stats) return null;
return (
{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시간 차트 */}
{/* 검색 + 필터 */}
{/* 배치 리스트 */}
{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) => (
))}
)}
);
}