ERP-node/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx

709 lines
32 KiB
TypeScript

"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 (
<div className="flex h-8 items-end gap-[2px]">
{Array.from({ length: 24 }).map((_, i) => (
<div key={i} className="min-w-[4px] flex-1 rounded-t-sm bg-muted-foreground/10" style={{ height: "8%" }} />
))}
</div>
);
}
return (
<div className="flex h-8 items-end gap-[2px]">
{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 (
<div
key={i}
className={`min-w-[4px] flex-1 rounded-t-sm transition-colors ${colorClass}`}
style={{ height }}
title={`${slot.hour?.slice(11, 16) || i}시 | 성공: ${slot.success} 실패: ${slot.failed}`}
/>
);
})}
</div>
);
}
function ExecutionTimeline({ logs }: { logs: RecentLog[] }) {
if (!logs || logs.length === 0) {
return <p className="py-6 text-center text-xs text-muted-foreground"> </p>;
}
return (
<div className="flex flex-col">
{logs.map((log, i) => {
const isSuccess = log.status === "SUCCESS";
const isFail = log.status === "FAILED";
const isLast = i === logs.length - 1;
return (
<div key={log.id} className="flex items-start gap-3 py-2.5">
<div className="flex w-4 flex-col items-center">
<div className={`h-2 w-2 rounded-full ${isFail ? "bg-destructive" : isSuccess ? "bg-emerald-500" : "bg-amber-500 animate-pulse"}`} />
{!isLast && <div className="mt-1 min-h-[12px] w-px bg-border/50" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] font-medium">
{log.started_at ? new Date(log.started_at).toLocaleTimeString("ko-KR") : "-"}
</span>
<span className={`rounded px-1.5 py-0.5 text-[9px] font-bold ${isFail ? "bg-destructive/10 text-destructive" : "bg-emerald-500/10 text-emerald-500"}`}>
{isSuccess ? "성공" : isFail ? "실패" : log.status}
</span>
</div>
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">
{isFail ? log.error_message || "알 수 없는 오류" : `${(log.total_records || 0).toLocaleString()}건 / ${((log.duration_ms || 0) / 1000).toFixed(1)}`}
</p>
</div>
</div>
);
})}
</div>
);
}
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 (
<div className="border-t bg-muted/20 px-6 py-5">
<p className="mb-4 text-xs text-muted-foreground">{narrative}</p>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-5">
<div>
<div className="mb-2 flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-muted-foreground"> 24</span>
</div>
<Sparkline data={sparkline} />
</div>
{batchType !== "node-flow" && mappings.length > 0 && (
<div>
<div className="mb-2 flex items-center gap-1.5">
<Link className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-muted-foreground"> </span>
<Badge variant="secondary" className="ml-1 h-4 px-1 text-[9px]">{mappings.length}</Badge>
</div>
<div className="space-y-0.5">
{mappings.slice(0, 5).map((m, i) => (
<div key={i} className="flex items-center gap-1.5 rounded px-2 py-1 text-[11px]">
<span className="font-mono font-medium text-cyan-500">{m.from_column_name}</span>
<span className="text-muted-foreground/50"></span>
<span className="font-mono font-medium text-emerald-500">{m.to_column_name}</span>
{batch.conflict_key === m.to_column_name && (
<Badge variant="outline" className="ml-auto h-3.5 px-1 text-[8px] text-emerald-500 border-emerald-500/30">PK</Badge>
)}
</div>
))}
{mappings.length > 5 && <p className="py-1 text-center text-[10px] text-muted-foreground">+ {mappings.length - 5} </p>}
</div>
</div>
)}
{batchType === "node-flow" && batch.node_flow_id && (
<div className="flex items-center gap-3 rounded-lg bg-indigo-500/5 p-3">
<Workflow className="h-5 w-5 text-indigo-500" />
<div>
<p className="text-xs font-medium"> #{batch.node_flow_id}</p>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
</div>
)}
<div>
<div className="mb-2 flex items-center gap-1.5">
<Settings className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-muted-foreground"></span>
</div>
<div className="space-y-0">
{batch.save_mode && (
<div className="flex items-center justify-between py-1">
<span className="text-[11px] text-muted-foreground"> </span>
<Badge variant="secondary" className="h-4 px-1.5 text-[9px]">{batch.save_mode}</Badge>
</div>
)}
{batch.conflict_key && (
<div className="flex items-center justify-between py-1">
<span className="text-[11px] text-muted-foreground"> </span>
<span className="font-mono text-[10px]">{batch.conflict_key}</span>
</div>
)}
</div>
</div>
</div>
<div>
<div className="mb-2 flex items-center gap-1.5">
<BarChart3 className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-muted-foreground"> </span>
<Badge variant="secondary" className="ml-1 h-4 px-1 text-[9px]"> 5</Badge>
</div>
<ExecutionTimeline logs={recentLogs} />
</div>
</div>
</div>
);
}
function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
if (!stats) return null;
return (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> 24 </span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-destructive" />
</span>
</div>
</div>
<div className="flex h-10 items-end gap-[3px]">
{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 (
<div
key={i}
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/8"}`}
style={{ height: `${h}%` }}
/>
);
})}
</div>
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
<span>12 </span>
<span>6 </span>
<span></span>
</div>
</div>
);
}
export default function BatchManagementPage() {
const { openTab } = useTabStore();
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
const [expandedBatch, setExpandedBatch] = useState<number | null>(null);
const [stats, setStats] = useState<BatchStats | null>(null);
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
const [togglingBatch, setTogglingBatch] = useState<number | null>(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<number, SparklineData[]> = {};
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 (
<div className="flex min-h-screen flex-col bg-background">
<div className="mx-auto w-full max-w-[720px] space-y-4 px-4 py-6 sm:px-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-bold tracking-tight"> </h1>
<p className="text-xs text-muted-foreground"> </p>
</div>
<div className="flex items-center gap-2">
<button onClick={loadBatchConfigs} disabled={loading} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</button>
<Button size="sm" onClick={() => setIsBatchTypeModalOpen(true)} className="h-8 gap-1 text-xs">
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 통계 요약 스트립 */}
{stats && (
<div className="flex items-center gap-0 rounded-lg border bg-card">
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"></span>
<span className="text-lg font-bold">{batchConfigs.length}</span>
</div>
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"> </span>
<span className="text-lg font-bold text-primary">{activeBatches}</span>
</div>
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"> </span>
<span className="text-lg font-bold text-emerald-600">{stats.todayExecutions}</span>
{execDiff !== 0 && (
<span className={`text-[10px] ${execDiff > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
{execDiff > 0 ? "+" : ""}{execDiff}
</span>
)}
</div>
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"></span>
<span className={`text-lg font-bold ${stats.todayFailures > 0 ? "text-destructive" : "text-muted-foreground"}`}>
{stats.todayFailures}
</span>
{failDiff !== 0 && (
<span className={`text-[10px] ${failDiff > 0 ? "text-destructive" : "text-emerald-500"}`}>
{failDiff > 0 ? "+" : ""}{failDiff}
</span>
)}
</div>
</div>
)}
{/* 24시간 차트 */}
<GlobalSparkline stats={stats} />
{/* 검색 + 필터 */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[180px] flex-1">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="배치 이름으로 검색하세요" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="h-8 pl-9 text-xs" />
</div>
<div className="flex gap-0.5 rounded-lg border bg-muted/30 p-0.5">
{([
{ value: "all", label: `전체 ${batchConfigs.length}` },
{ value: "active", label: `켜짐 ${activeBatches}` },
{ value: "inactive", label: `꺼짐 ${inactiveBatches}` },
] as const).map((item) => (
<button
key={item.value}
className={`rounded-md px-2.5 py-1 text-[11px] font-semibold transition-colors ${statusFilter === item.value ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setStatusFilter(item.value)}
>
{item.label}
</button>
))}
</div>
</div>
{/* 배치 리스트 */}
<div className="space-y-1.5">
{loading && batchConfigs.length === 0 && (
<div className="flex h-40 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!loading && filteredBatches.length === 0 && (
<div className="flex h-40 flex-col items-center justify-center gap-2">
<Database className="h-6 w-6 text-muted-foreground/40" />
<p className="text-xs text-muted-foreground">{searchTerm ? "검색 결과가 없어요" : "등록된 배치가 없어요"}</p>
</div>
)}
{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 (
<div key={batchId} className={`overflow-hidden rounded-lg border transition-all ${isExpanded ? "ring-1 ring-primary/20" : "hover:border-muted-foreground/20"} ${!isActive ? "opacity-55" : ""}`}>
{/* 행 */}
<div className="flex cursor-pointer items-center gap-3 px-4 py-3.5 sm:gap-4" onClick={() => handleRowClick(batchId)}>
{/* 토글 */}
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
<Switch
checked={isActive}
onCheckedChange={() => toggleBatchActive(batchId, batch.is_active || "N")}
disabled={isToggling}
className="scale-[0.7]"
/>
</div>
{/* 배치 이름 + 설명 */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{batch.batch_name}</p>
<p className="truncate text-[11px] text-muted-foreground">{batch.description || ""}</p>
</div>
{/* 타입 뱃지 */}
<span className={`hidden shrink-0 rounded border px-2 py-0.5 text-[10px] font-semibold sm:inline-flex ${typeStyle.className}`}>
{typeStyle.label}
</span>
{/* 스케줄 */}
<div className="hidden shrink-0 text-right sm:block" style={{ minWidth: 90 }}>
<p className="text-[12px] font-medium">{cronToKorean(batch.cron_schedule)}</p>
<p className="text-[10px] text-muted-foreground">
{getNextExecution(batch.cron_schedule, isActive)
? `다음: ${getNextExecution(batch.cron_schedule, isActive)}`
: ""}
</p>
</div>
{/* 인라인 미니 스파크라인 */}
<div className="hidden shrink-0 sm:block" style={{ width: 64 }}>
<Sparkline data={sparklineCache[batchId] || []} />
</div>
{/* 마지막 실행 */}
<div className="hidden shrink-0 text-right sm:block" style={{ minWidth: 70 }}>
{isExecuting ? (
<p className="text-[11px] font-semibold text-amber-500"> ...</p>
) : lastAt ? (
<>
<div className="flex items-center justify-end gap-1">
{isFailed ? (
<AlertCircle className="h-3 w-3 text-destructive" />
) : isSuccess ? (
<CheckCircle className="h-3 w-3 text-emerald-500" />
) : null}
<span className={`text-[11px] font-semibold ${isFailed ? "text-destructive" : "text-emerald-500"}`}>
{isFailed ? "실패" : "성공"}
</span>
</div>
<p className="text-[10px] text-muted-foreground">{timeAgo(lastAt)}</p>
</>
) : (
<p className="text-[11px] text-muted-foreground">&mdash;</p>
)}
</div>
{/* 액션 */}
<div className="flex shrink-0 items-center gap-0.5">
<button
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-emerald-500/10 hover:text-emerald-500"
onClick={(e) => executeBatch(e, batchId)}
disabled={isExecuting}
title="지금 실행하기"
>
{isExecuting ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={(e) => { e.stopPropagation(); openTab({ type: "admin", title: `배치 편집 #${batchId}`, adminUrl: `/admin/automaticMng/batchmngList/edit/${batchId}` }); }}
title="수정하기"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => deleteBatch(e, batchId, batch.batch_name)}
title="삭제하기"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<ChevronDown className={`ml-0.5 h-3.5 w-3.5 text-muted-foreground transition-transform ${isExpanded ? "rotate-180" : ""}`} />
</div>
</div>
{/* 모바일 메타 */}
<div className="flex items-center gap-2 px-4 pb-2 sm:hidden">
<span className={`rounded border px-1.5 py-0.5 text-[9px] font-semibold ${typeStyle.className}`}>{typeStyle.label}</span>
<span className="text-[10px] text-muted-foreground">{cronToKorean(batch.cron_schedule)}</span>
{lastAt && (
<span className={`ml-auto text-[10px] font-semibold ${isFailed ? "text-destructive" : "text-emerald-500"}`}>
{isFailed ? "실패" : "성공"} {timeAgo(lastAt)}
</span>
)}
</div>
{/* 확장 패널 */}
{isExpanded && (
<BatchDetailPanel batch={batch} sparkline={sparklineCache[batchId] || []} recentLogs={recentLogsCache[batchId] || []} />
)}
</div>
);
})}
</div>
{/* 배치 타입 선택 모달 */}
{isBatchTypeModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}>
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
<h2 className="mb-1 text-base font-bold"> ?</h2>
<p className="mb-5 text-xs text-muted-foreground"> </p>
<div className="space-y-2">
{[
{ 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) => (
<button
key={opt.type}
className="flex w-full items-center gap-3.5 rounded-lg border p-4 text-left transition-all hover:border-primary/30 hover:bg-primary/5"
onClick={() => handleBatchTypeSelect(opt.type)}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<opt.icon className={`h-[18px] w-[18px] ${opt.iconColor}`} />
</div>
<div>
<p className="text-sm font-semibold">{opt.title}</p>
<p className="text-[11px] text-muted-foreground">{opt.desc}</p>
</div>
</button>
))}
</div>
<button onClick={() => setIsBatchTypeModalOpen(false)} className="mt-4 w-full rounded-md border py-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
</button>
</div>
</div>
)}
</div>
<ScrollToTop />
</div>
);
}