"use client"; import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Play, Pencil, Trash2, ChevronDown, ChevronRight, Clock } 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; } export default function BatchCard({ batch, expanded, onToggleExpand, executingBatch, onExecute, onToggleStatus, onEdit, onDelete, cronToKorean, }: 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(); }; 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.description || "설명 없음"}

{typeLabel}

{batch.cron_schedule}

{cronToKorean(batch.cron_schedule)}

{expanded ? ( ) : (
{Array.from({ length: 24 }).map((_, i) => (
))}
)}

{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 || "-"}
)}
)} ); }