2025-09-24 10:46:55 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2026-03-18 14:37:57 +09:00
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
2025-09-24 10:46:55 +09:00
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
2026-03-18 14:37:57 +09:00
|
|
|
import {
|
|
|
|
|
Plus,
|
|
|
|
|
Search,
|
2025-09-25 11:04:16 +09:00
|
|
|
RefreshCw,
|
2026-03-18 14:37:57 +09:00
|
|
|
Database,
|
|
|
|
|
LayoutGrid,
|
|
|
|
|
CheckCircle,
|
|
|
|
|
Activity,
|
|
|
|
|
AlertTriangle,
|
2025-09-25 11:04:16 +09:00
|
|
|
} from "lucide-react";
|
2025-09-24 10:46:55 +09:00
|
|
|
import { toast } from "sonner";
|
2026-03-03 16:04:11 +09:00
|
|
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
2025-09-25 11:04:16 +09:00
|
|
|
import { useRouter } from "next/navigation";
|
2026-03-18 14:37:57 +09:00
|
|
|
import { BatchAPI, type BatchConfig } from "@/lib/api/batch";
|
|
|
|
|
import apiClient from "@/lib/api/client";
|
2025-10-22 14:52:13 +09:00
|
|
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
2026-03-18 14:37:57 +09:00
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
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";
|
2025-09-24 10:46:55 +09:00
|
|
|
|
|
|
|
|
export default function BatchManagementPage() {
|
2025-09-25 11:04:16 +09:00
|
|
|
const router = useRouter();
|
|
|
|
|
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
|
2025-09-24 10:46:55 +09:00
|
|
|
const [loading, setLoading] = useState(false);
|
2026-03-18 14:37:57 +09:00
|
|
|
const [stats, setStats] = useState<BatchStats | null>(null);
|
|
|
|
|
const [statsLoading, setStatsLoading] = useState(false);
|
2025-09-25 11:04:16 +09:00
|
|
|
const [searchTerm, setSearchTerm] = useState("");
|
2026-03-18 14:37:57 +09:00
|
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
|
|
|
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
|
2025-09-25 11:04:16 +09:00
|
|
|
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
2026-03-18 14:37:57 +09:00
|
|
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
2025-09-26 17:29:20 +09:00
|
|
|
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
2025-09-24 10:46:55 +09:00
|
|
|
|
2026-03-18 14:37:57 +09:00
|
|
|
const loadBatchConfigs = useCallback(async () => {
|
2025-09-25 11:04:16 +09:00
|
|
|
setLoading(true);
|
2025-09-24 10:46:55 +09:00
|
|
|
try {
|
2025-09-25 11:04:16 +09:00
|
|
|
const response = await BatchAPI.getBatchConfigs({
|
2026-03-18 14:37:57 +09:00
|
|
|
limit: 500,
|
2025-09-25 11:04:16 +09:00
|
|
|
search: searchTerm || undefined,
|
|
|
|
|
});
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
setBatchConfigs(response.data);
|
|
|
|
|
} else {
|
|
|
|
|
setBatchConfigs([]);
|
|
|
|
|
}
|
2025-09-24 10:46:55 +09:00
|
|
|
} catch (error) {
|
2025-09-25 11:04:16 +09:00
|
|
|
console.error("배치 목록 조회 실패:", error);
|
|
|
|
|
toast.error("배치 목록을 불러오는데 실패했습니다.");
|
|
|
|
|
setBatchConfigs([]);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
2025-09-24 10:46:55 +09:00
|
|
|
}
|
2026-03-18 14:37:57 +09:00
|
|
|
}, [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]);
|
2025-09-24 10:46:55 +09:00
|
|
|
|
2025-09-25 11:04:16 +09:00
|
|
|
const executeBatch = async (batchId: number) => {
|
|
|
|
|
setExecutingBatch(batchId);
|
2025-09-24 10:46:55 +09:00
|
|
|
try {
|
2025-09-25 11:04:16 +09:00
|
|
|
const response = await BatchAPI.executeBatchConfig(batchId);
|
|
|
|
|
if (response.success) {
|
2026-03-18 14:37:57 +09:00
|
|
|
toast.success(
|
|
|
|
|
`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords ?? 0}개, 성공: ${response.data?.successRecords ?? 0}개)`
|
|
|
|
|
);
|
|
|
|
|
loadBatchConfigs();
|
|
|
|
|
loadStats();
|
2025-09-25 11:04:16 +09:00
|
|
|
} else {
|
|
|
|
|
toast.error("배치 실행에 실패했습니다.");
|
|
|
|
|
}
|
2025-09-24 10:46:55 +09:00
|
|
|
} catch (error) {
|
2026-03-03 16:04:11 +09:00
|
|
|
showErrorToast("배치 실행에 실패했습니다", error, {
|
|
|
|
|
guidance: "배치 설정을 확인하고 다시 시도해 주세요.",
|
|
|
|
|
});
|
2025-09-25 11:04:16 +09:00
|
|
|
} finally {
|
|
|
|
|
setExecutingBatch(null);
|
2025-09-24 10:46:55 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-25 11:04:16 +09:00
|
|
|
const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
|
2025-09-24 10:46:55 +09:00
|
|
|
try {
|
2026-03-18 14:37:57 +09:00
|
|
|
const newStatus = currentStatus === "Y" ? "N" : "Y";
|
|
|
|
|
await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus === "Y" });
|
|
|
|
|
toast.success(`배치가 ${newStatus === "Y" ? "활성화" : "비활성화"}되었습니다.`);
|
|
|
|
|
loadBatchConfigs();
|
|
|
|
|
loadStats();
|
2025-09-24 10:46:55 +09:00
|
|
|
} catch (error) {
|
2026-03-18 14:37:57 +09:00
|
|
|
console.error("배치 상태 변경 실패:", error);
|
2025-09-25 11:04:16 +09:00
|
|
|
toast.error("배치 상태 변경에 실패했습니다.");
|
2025-09-24 10:46:55 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-25 11:04:16 +09:00
|
|
|
const deleteBatch = async (batchId: number, batchName: string) => {
|
2026-03-18 14:37:57 +09:00
|
|
|
if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) return;
|
2025-09-24 10:46:55 +09:00
|
|
|
try {
|
2025-09-25 11:04:16 +09:00
|
|
|
await BatchAPI.deleteBatchConfig(batchId);
|
|
|
|
|
toast.success("배치가 삭제되었습니다.");
|
2026-03-18 14:37:57 +09:00
|
|
|
loadBatchConfigs();
|
|
|
|
|
loadStats();
|
2025-09-24 10:46:55 +09:00
|
|
|
} catch (error) {
|
2025-09-25 11:04:16 +09:00
|
|
|
console.error("배치 삭제 실패:", error);
|
|
|
|
|
toast.error("배치 삭제에 실패했습니다.");
|
2025-09-24 10:46:55 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-18 14:37:57 +09:00
|
|
|
const handleCreateBatch = () => setIsBatchTypeModalOpen(true);
|
2025-09-26 17:29:20 +09:00
|
|
|
|
2026-03-18 14:37:57 +09:00
|
|
|
const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db") => {
|
2025-09-26 17:29:20 +09:00
|
|
|
setIsBatchTypeModalOpen(false);
|
2026-03-18 14:37:57 +09:00
|
|
|
if (type === "db-to-db") {
|
|
|
|
|
router.push("/admin/batchmng/create");
|
|
|
|
|
} else {
|
|
|
|
|
router.push("/admin/batch-management-new");
|
2025-09-26 17:29:20 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-18 14:37:57 +09:00
|
|
|
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]);
|
|
|
|
|
|
2025-09-24 10:46:55 +09:00
|
|
|
return (
|
2025-10-22 14:52:13 +09:00
|
|
|
<div className="flex min-h-screen flex-col bg-background">
|
|
|
|
|
<div className="space-y-6 p-6">
|
2026-03-18 14:37:57 +09:00
|
|
|
{/* 헤더 */}
|
|
|
|
|
<div className="flex flex-col gap-4 border-b pb-4 sm:flex-row sm:items-end sm:justify-between">
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<h1 className="text-xl font-extrabold tracking-tight sm:text-3xl">배치 관리</h1>
|
|
|
|
|
<p className="text-xs text-muted-foreground sm:text-sm">
|
|
|
|
|
데이터 동기화 배치 작업을 모니터링하고 관리합니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Button variant="outline" size="default" onClick={loadBatchConfigs} disabled={loading} className="gap-2">
|
|
|
|
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
2025-10-22 14:52:13 +09:00
|
|
|
새로고침
|
|
|
|
|
</Button>
|
2026-03-18 14:37:57 +09:00
|
|
|
<Button size="default" onClick={handleCreateBatch} className="gap-2">
|
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
새 배치
|
|
|
|
|
</Button>
|
2025-10-22 14:52:13 +09:00
|
|
|
</div>
|
2026-03-18 14:37:57 +09:00
|
|
|
</div>
|
2025-10-22 14:52:13 +09:00
|
|
|
|
2026-03-18 14:37:57 +09:00
|
|
|
{/* 통계 카드 4개 */}
|
|
|
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
|
|
|
<div className="flex justify-between rounded-xl border bg-card p-4">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">전체 배치</p>
|
|
|
|
|
<p className="mt-1 font-mono text-2xl font-extrabold sm:text-3xl">
|
|
|
|
|
{statsLoading ? "-" : (stats?.totalBatches ?? 0).toLocaleString()}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-500/10">
|
|
|
|
|
<LayoutGrid className="h-5 w-5 text-indigo-500" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-between rounded-xl border bg-card p-4">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">활성 배치</p>
|
|
|
|
|
<p className="mt-1 font-mono text-2xl font-extrabold text-green-600 sm:text-3xl">
|
|
|
|
|
{statsLoading ? "-" : (stats?.activeBatches ?? 0).toLocaleString()}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-green-500/10">
|
|
|
|
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-between rounded-xl border bg-card p-4">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">오늘 실행</p>
|
|
|
|
|
<p className="mt-1 font-mono text-2xl font-extrabold text-cyan-600 sm:text-3xl">
|
|
|
|
|
{statsLoading ? "-" : (stats?.todayExecutions ?? 0).toLocaleString()}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-cyan-500/10">
|
|
|
|
|
<Activity className="h-5 w-5 text-cyan-500" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-between rounded-xl border bg-card p-4">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">오늘 실패</p>
|
|
|
|
|
<p className="mt-1 font-mono text-2xl font-extrabold text-destructive sm:text-3xl">
|
|
|
|
|
{statsLoading ? "-" : (stats?.todayFailures ?? 0).toLocaleString()}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
|
|
|
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
2025-10-22 14:52:13 +09:00
|
|
|
</div>
|
2025-09-24 10:46:55 +09:00
|
|
|
</div>
|
2025-10-22 14:52:13 +09:00
|
|
|
</div>
|
2025-09-24 10:46:55 +09:00
|
|
|
|
2026-03-18 14:37:57 +09:00
|
|
|
{/* 툴바 */}
|
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
|
|
|
<div className="relative max-w-[320px] flex-1">
|
|
|
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="배치 이름으로 검색..."
|
|
|
|
|
value={searchTerm}
|
|
|
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
|
|
|
className="h-9 rounded-lg border bg-card pl-9 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<div className="flex gap-0.5 rounded-lg border bg-card p-0.5">
|
|
|
|
|
{(["all", "active", "inactive"] as const).map((s) => (
|
|
|
|
|
<button
|
|
|
|
|
key={s}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setStatusFilter(s)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"rounded-md px-3 py-1.5 text-[11px] font-semibold",
|
|
|
|
|
statusFilter === s ? "bg-primary/10 text-primary" : "text-muted-foreground hover:opacity-80"
|
|
|
|
|
)}
|
2025-09-24 10:46:55 +09:00
|
|
|
>
|
2026-03-18 14:37:57 +09:00
|
|
|
{s === "all" ? "전체" : s === "active" ? "활성" : "비활성"}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-0.5 rounded-lg border bg-card p-0.5">
|
|
|
|
|
{(["all", "mapping", "restapi", "node_flow"] as const).map((t) => (
|
|
|
|
|
<button
|
|
|
|
|
key={t}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setTypeFilter(t)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"rounded-md px-3 py-1.5 text-[11px] font-semibold",
|
|
|
|
|
typeFilter === t ? "bg-primary/10 text-primary" : "text-muted-foreground hover:opacity-80"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{t === "all" ? "전체" : t === "mapping" ? "DB-DB" : t === "restapi" ? "API-DB" : "노드 플로우"}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
2025-09-24 10:46:55 +09:00
|
|
|
</div>
|
2025-10-22 14:52:13 +09:00
|
|
|
</div>
|
2026-03-18 14:37:57 +09:00
|
|
|
<div className="ml-auto text-[11px] text-muted-foreground">
|
|
|
|
|
총 <span className="font-bold text-foreground">{filteredBatches.length}</span>건
|
2025-10-22 14:52:13 +09:00
|
|
|
</div>
|
2026-03-18 14:37:57 +09:00
|
|
|
</div>
|
2025-09-24 10:46:55 +09:00
|
|
|
|
2026-03-18 14:37:57 +09:00
|
|
|
{/* 배치 테이블 */}
|
|
|
|
|
{filteredBatches.length === 0 ? (
|
|
|
|
|
<div className="flex h-64 flex-col items-center justify-center rounded-xl border bg-card">
|
|
|
|
|
<Database className="h-12 w-12 text-muted-foreground" />
|
|
|
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
|
|
|
{searchTerm || statusFilter !== "all" || typeFilter !== "all" ? "검색 결과가 없습니다." : "배치가 없습니다."}
|
|
|
|
|
</p>
|
|
|
|
|
{!searchTerm && statusFilter === "all" && typeFilter === "all" && (
|
|
|
|
|
<Button onClick={handleCreateBatch} className="mt-4 gap-2" size="sm">
|
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
첫 배치 추가
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="overflow-hidden rounded-xl border bg-card">
|
|
|
|
|
<table className="w-full table-fixed border-collapse" style={{ tableLayout: "fixed" }}>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr className="h-10 bg-muted/50">
|
|
|
|
|
<th className="w-[44px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground" />
|
|
|
|
|
<th className="border-b px-3 text-left text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
|
|
|
|
배치
|
|
|
|
|
</th>
|
|
|
|
|
<th className="w-[100px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
|
|
|
|
타입
|
|
|
|
|
</th>
|
|
|
|
|
<th className="w-[130px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
|
|
|
|
스케줄
|
|
|
|
|
</th>
|
|
|
|
|
<th className="w-[160px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
|
|
|
|
최근 24h
|
|
|
|
|
</th>
|
|
|
|
|
<th className="w-[100px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
|
|
|
|
마지막 실행
|
|
|
|
|
</th>
|
|
|
|
|
<th className="w-[120px] border-b px-2 text-right text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
|
|
|
|
액션
|
|
|
|
|
</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{filteredBatches.map((batch) => (
|
|
|
|
|
<BatchCard
|
|
|
|
|
key={batch.id}
|
|
|
|
|
batch={batch}
|
|
|
|
|
expanded={expandedId === batch.id}
|
|
|
|
|
onToggleExpand={() => 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}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
2025-09-25 11:04:16 +09:00
|
|
|
</div>
|
2025-10-22 14:52:13 +09:00
|
|
|
)}
|
2025-09-26 17:29:20 +09:00
|
|
|
|
2025-10-22 14:52:13 +09:00
|
|
|
{/* 배치 타입 선택 모달 */}
|
|
|
|
|
{isBatchTypeModalOpen && (
|
|
|
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
|
|
|
|
<div className="w-full max-w-2xl rounded-lg border bg-card p-6 shadow-lg">
|
2026-03-18 14:37:57 +09:00
|
|
|
<h2 className="text-center text-xl font-semibold">배치 타입 선택</h2>
|
|
|
|
|
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
|
|
|
|
|
onClick={() => handleBatchTypeSelect("db-to-db")}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Database className="h-8 w-8 text-primary" />
|
|
|
|
|
<span className="text-muted-foreground">→</span>
|
|
|
|
|
<Database className="h-8 w-8 text-primary" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1 text-center">
|
|
|
|
|
<div className="text-lg font-medium">DB → DB</div>
|
|
|
|
|
<div className="text-sm text-muted-foreground">데이터베이스 간 데이터 동기화</div>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
|
|
|
|
|
onClick={() => handleBatchTypeSelect("restapi-to-db")}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-2xl text-muted-foreground">API</span>
|
|
|
|
|
<span className="text-muted-foreground">→</span>
|
|
|
|
|
<Database className="h-8 w-8 text-primary" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1 text-center">
|
|
|
|
|
<div className="text-lg font-medium">REST API → DB</div>
|
|
|
|
|
<div className="text-sm text-muted-foreground">REST API에서 DB로 데이터 수집</div>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-6 flex justify-center">
|
|
|
|
|
<Button variant="outline" onClick={() => setIsBatchTypeModalOpen(false)}>
|
|
|
|
|
취소
|
|
|
|
|
</Button>
|
2025-09-26 17:29:20 +09:00
|
|
|
</div>
|
2025-10-22 14:52:13 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<ScrollToTop />
|
2025-09-24 10:46:55 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
2026-03-18 14:37:57 +09:00
|
|
|
}
|