[agent-pipeline] rollback to 7f33b3fd

This commit is contained in:
DDD1542 2026-03-18 14:37:57 +09:00
parent cbb8b24e70
commit cd0f0df34d
2 changed files with 408 additions and 676 deletions

View File

@ -1,102 +1,57 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback, useMemo } from "react"; import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Plus, Plus,
Search, Search,
RefreshCw, RefreshCw,
Database, Database
LayoutGrid,
CheckCircle,
Activity,
AlertTriangle,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils"; import { showErrorToast } from "@/lib/utils/toastUtils";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { BatchAPI, type BatchConfig } from "@/lib/api/batch"; import {
import apiClient from "@/lib/api/client"; BatchAPI,
import { ScrollToTop } from "@/components/common/ScrollToTop"; BatchConfig,
import { cn } from "@/lib/utils"; BatchMapping,
} from "@/lib/api/batch";
import BatchCard from "@/components/admin/BatchCard"; import BatchCard from "@/components/admin/BatchCard";
import { ScrollToTop } from "@/components/common/ScrollToTop";
// 대시보드 통계 타입 (백엔드 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";
export default function BatchManagementPage() { export default function BatchManagementPage() {
const router = useRouter(); const router = useRouter();
// 상태 관리
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]); const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<BatchStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all"); const [currentPage, setCurrentPage] = useState(1);
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all"); const [totalPages, setTotalPages] = useState(1);
const [executingBatch, setExecutingBatch] = useState<number | null>(null); const [executingBatch, setExecutingBatch] = useState<number | null>(null);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false); const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
const loadBatchConfigs = useCallback(async () => { // 페이지 로드 시 배치 목록 조회
useEffect(() => {
loadBatchConfigs();
}, [currentPage, searchTerm]);
// 배치 설정 목록 조회
const loadBatchConfigs = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await BatchAPI.getBatchConfigs({ const response = await BatchAPI.getBatchConfigs({
limit: 500, page: currentPage,
limit: 10,
search: searchTerm || undefined, search: searchTerm || undefined,
}); });
if (response.success && response.data) { if (response.success && response.data) {
setBatchConfigs(response.data); setBatchConfigs(response.data);
if (response.pagination) {
setTotalPages(response.pagination.totalPages);
}
} else { } else {
setBatchConfigs([]); setBatchConfigs([]);
} }
@ -107,46 +62,20 @@ export default function BatchManagementPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [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]);
// 배치 수동 실행
const executeBatch = async (batchId: number) => { const executeBatch = async (batchId: number) => {
setExecutingBatch(batchId); setExecutingBatch(batchId);
try { try {
const response = await BatchAPI.executeBatchConfig(batchId); const response = await BatchAPI.executeBatchConfig(batchId);
if (response.success) { if (response.success) {
toast.success( toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`);
`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords ?? 0}개, 성공: ${response.data?.successRecords ?? 0}개)`
);
loadBatchConfigs();
loadStats();
} else { } else {
toast.error("배치 실행에 실패했습니다."); toast.error("배치 실행에 실패했습니다.");
} }
} catch (error) { } catch (error) {
console.error("배치 실행 실패:", error);
showErrorToast("배치 실행에 실패했습니다", error, { showErrorToast("배치 실행에 실패했습니다", error, {
guidance: "배치 설정을 확인하고 다시 시도해 주세요.", guidance: "배치 설정을 확인하고 다시 시도해 주세요.",
}); });
@ -155,230 +84,226 @@ export default function BatchManagementPage() {
} }
}; };
// 배치 활성화/비활성화 토글
const toggleBatchStatus = async (batchId: number, currentStatus: string) => { const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus });
try { try {
const newStatus = currentStatus === "Y" ? "N" : "Y"; const newStatus = currentStatus === 'Y' ? 'N' : 'Y';
await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus === "Y" }); console.log("📝 새로운 상태:", newStatus);
toast.success(`배치가 ${newStatus === "Y" ? "활성화" : "비활성화"}되었습니다.`);
loadBatchConfigs(); const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus });
loadStats(); console.log("✅ API 호출 성공:", result);
toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`);
loadBatchConfigs(); // 목록 새로고침
} catch (error) { } catch (error) {
console.error("배치 상태 변경 실패:", error); console.error("배치 상태 변경 실패:", error);
toast.error("배치 상태 변경에 실패했습니다."); toast.error("배치 상태 변경에 실패했습니다.");
} }
}; };
// 배치 삭제
const deleteBatch = async (batchId: number, batchName: string) => { const deleteBatch = async (batchId: number, batchName: string) => {
if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) return; if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) {
return;
}
try { try {
await BatchAPI.deleteBatchConfig(batchId); await BatchAPI.deleteBatchConfig(batchId);
toast.success("배치가 삭제되었습니다."); toast.success("배치가 삭제되었습니다.");
loadBatchConfigs(); loadBatchConfigs(); // 목록 새로고침
loadStats();
} catch (error) { } catch (error) {
console.error("배치 삭제 실패:", error); console.error("배치 삭제 실패:", error);
toast.error("배치 삭제에 실패했습니다."); toast.error("배치 삭제에 실패했습니다.");
} }
}; };
const handleCreateBatch = () => setIsBatchTypeModalOpen(true); // 검색 처리
const handleSearch = (value: string) => {
const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db") => { setSearchTerm(value);
setIsBatchTypeModalOpen(false); setCurrentPage(1); // 검색 시 첫 페이지로 이동
if (type === "db-to-db") {
router.push("/admin/batchmng/create");
} else {
router.push("/admin/batch-management-new");
}
}; };
const filteredBatches = useMemo(() => { // 매핑 정보 요약 생성
let list = batchConfigs; const getMappingSummary = (mappings: BatchMapping[]) => {
if (statusFilter === "active") list = list.filter((b) => b.is_active === "Y"); if (!mappings || mappings.length === 0) {
else if (statusFilter === "inactive") list = list.filter((b) => b.is_active !== "Y"); return "매핑 없음";
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"); const tableGroups = new Map<string, number>();
else if (typeFilter === "node_flow") list = list.filter((b) => et(b) === "node_flow"); mappings.forEach(mapping => {
return list; const key = `${mapping.from_table_name}${mapping.to_table_name}`;
}, [batchConfigs, statusFilter, typeFilter]); tableGroups.set(key, (tableGroups.get(key) || 0) + 1);
});
const summaries = Array.from(tableGroups.entries()).map(([key, count]) =>
`${key} (${count}개 컬럼)`
);
return summaries.join(", ");
};
// 배치 추가 버튼 클릭 핸들러
const handleCreateBatch = () => {
setIsBatchTypeModalOpen(true);
};
// 배치 타입 선택 핸들러
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
console.log("배치 타입 선택:", type);
setIsBatchTypeModalOpen(false);
if (type === 'db-to-db') {
// 기존 DB → DB 배치 생성 페이지로 이동
console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create');
router.push('/admin/batchmng/create');
} else if (type === 'restapi-to-db') {
// 새로운 REST API 배치 페이지로 이동
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
try {
router.push('/admin/batch-management-new');
console.log("라우터 push 실행 완료");
} catch (error) {
console.error("라우터 push 오류:", error);
// 대안: window.location 사용
window.location.href = '/admin/batch-management-new';
}
}
};
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* 헤더 */} {/* 페이지 헤더 */}
<div className="flex flex-col gap-4 border-b pb-4 sm:flex-row sm:items-end sm:justify-between"> <div className="space-y-2 border-b pb-4">
<div className="space-y-1"> <h1 className="text-3xl font-bold tracking-tight"> </h1>
<h1 className="text-xl font-extrabold tracking-tight sm:text-3xl"> </h1> <p className="text-sm text-muted-foreground"> .</p>
<p className="text-xs text-muted-foreground sm:text-sm"> </div>
.
</p> {/* 검색 및 액션 영역 */}
</div> <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex gap-2"> {/* 검색 영역 */}
<Button variant="outline" size="default" onClick={loadBatchConfigs} disabled={loading} className="gap-2"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} /> <div className="w-full sm:w-[400px]">
<div className="relative">
<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) => handleSearch(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
</div>
<Button
variant="outline"
onClick={loadBatchConfigs}
disabled={loading}
className="h-10 gap-2 text-sm font-medium"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button> </Button>
<Button size="default" onClick={handleCreateBatch} className="gap-2"> </div>
{/* 액션 버튼 영역 */}
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
{" "}
<span className="font-semibold text-foreground">
{batchConfigs.length.toLocaleString()}
</span>{" "}
</div>
<Button
onClick={handleCreateBatch}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
{/* 통계 카드 4개 */} {/* 배치 목록 */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"> {batchConfigs.length === 0 ? (
<div className="flex justify-between rounded-xl border bg-card p-4"> <div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div> <div className="flex flex-col items-center gap-4 text-center">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> </p> <Database className="h-12 w-12 text-muted-foreground" />
<p className="mt-1 font-mono text-2xl font-extrabold sm:text-3xl"> <div className="space-y-2">
{statsLoading ? "-" : (stats?.totalBatches ?? 0).toLocaleString()} <h3 className="text-lg font-semibold"> </h3>
</p> <p className="text-sm text-muted-foreground">
</div> {searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-500/10"> </p>
<LayoutGrid className="h-5 w-5 text-indigo-500" /> </div>
</div> {!searchTerm && (
</div> <Button
<div className="flex justify-between rounded-xl border bg-card p-4"> onClick={handleCreateBatch}
<div> className="h-10 gap-2 text-sm font-medium"
<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" />
</div>
</div>
</div>
{/* 툴바 */}
<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"
)}
> >
{s === "all" ? "전체" : s === "active" ? "활성" : "비활성"} <Plus className="h-4 w-4" />
</button>
))} </Button>
)}
</div> </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>
))}
</div>
</div>
<div className="ml-auto text-[11px] text-muted-foreground">
<span className="font-bold text-foreground">{filteredBatches.length}</span>
</div>
</div>
{/* 배치 테이블 */}
{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>
) : ( ) : (
<div className="overflow-hidden rounded-xl border bg-card"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
<table className="w-full table-fixed border-collapse" style={{ tableLayout: "fixed" }}> {batchConfigs.map((batch) => (
<thead> <BatchCard
<tr className="h-10 bg-muted/50"> key={batch.id}
<th className="w-[44px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground" /> batch={batch}
<th className="border-b px-3 text-left text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> executingBatch={executingBatch}
onExecute={executeBatch}
</th> onToggleStatus={(batchId, currentStatus) => {
<th className="w-[100px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> toggleBatchStatus(batchId, currentStatus);
}}
</th> onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
<th className="w-[130px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> onDelete={deleteBatch}
getMappingSummary={getMappingSummary}
</th> />
<th className="w-[160px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> ))}
24h </div>
</th> )}
<th className="w-[100px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{/* 페이지네이션 */}
</th> {totalPages > 1 && (
<th className="w-[120px] border-b px-2 text-right text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> <div className="flex items-center justify-center gap-2">
<Button
</th> variant="outline"
</tr> onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
</thead> disabled={currentPage === 1}
<tbody> className="h-10 text-sm font-medium"
{filteredBatches.map((batch) => ( >
<BatchCard
key={batch.id} </Button>
batch={batch}
expanded={expandedId === batch.id} <div className="flex items-center gap-1">
onToggleExpand={() => setExpandedId((id) => (id === batch.id ? null : batch.id ?? null))} {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
executingBatch={executingBatch} const pageNum = i + 1;
onExecute={executeBatch} return (
onToggleStatus={toggleBatchStatus} <Button
onEdit={(id) => router.push(`/admin/batchmng/edit/${id}`)} key={pageNum}
onDelete={deleteBatch} variant={currentPage === pageNum ? "default" : "outline"}
cronToKorean={cronToKorean} onClick={() => setCurrentPage(pageNum)}
/> className="h-10 min-w-[40px] text-sm"
))} >
</tbody> {pageNum}
</table> </Button>
);
})}
</div>
<Button
variant="outline"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="h-10 text-sm font-medium"
>
</Button>
</div> </div>
)} )}
@ -386,48 +311,59 @@ export default function BatchManagementPage() {
{isBatchTypeModalOpen && ( {isBatchTypeModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"> <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"> <div className="w-full max-w-2xl rounded-lg border bg-card p-6 shadow-lg">
<h2 className="text-center text-xl font-semibold"> </h2> <div className="space-y-6">
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2"> <h2 className="text-xl font-semibold text-center"> </h2>
<button
type="button" <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
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" {/* DB → DB */}
onClick={() => handleBatchTypeSelect("db-to-db")} <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"
<div className="flex items-center gap-2"> onClick={() => handleBatchTypeSelect('db-to-db')}
<Database className="h-8 w-8 text-primary" /> >
<span className="text-muted-foreground"></span> <div className="flex items-center gap-2">
<Database className="h-8 w-8 text-primary" /> <Database className="h-8 w-8 text-primary" />
</div> <span className="text-muted-foreground"></span>
<div className="space-y-1 text-center"> <Database className="h-8 w-8 text-primary" />
<div className="text-lg font-medium">DB DB</div> </div>
<div className="text-sm text-muted-foreground"> </div> <div className="space-y-1 text-center">
</div> <div className="text-lg font-medium">DB DB</div>
</button> <div className="text-sm text-muted-foreground"> </div>
<button </div>
type="button" </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")} {/* REST API → DB */}
> <button
<div className="flex items-center gap-2"> 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"
<span className="text-2xl text-muted-foreground">API</span> onClick={() => handleBatchTypeSelect('restapi-to-db')}
<span className="text-muted-foreground"></span> >
<Database className="h-8 w-8 text-primary" /> <div className="flex items-center gap-2">
</div> <span className="text-2xl">🌐</span>
<div className="space-y-1 text-center"> <span className="text-muted-foreground"></span>
<div className="text-lg font-medium">REST API DB</div> <Database className="h-8 w-8 text-primary" />
<div className="text-sm text-muted-foreground">REST API에서 DB로 </div> </div>
</div> <div className="space-y-1 text-center">
</button> <div className="text-lg font-medium">REST API DB</div>
</div> <div className="text-sm text-muted-foreground">REST API에서 </div>
<div className="mt-6 flex justify-center"> </div>
<Button variant="outline" onClick={() => setIsBatchTypeModalOpen(false)}> </button>
</div>
</Button>
<div className="flex justify-center pt-2">
<Button
variant="outline"
onClick={() => setIsBatchTypeModalOpen(false)}
className="h-10 text-sm font-medium"
>
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* Scroll to Top 버튼 */}
<ScrollToTop /> <ScrollToTop />
</div> </div>
); );

View File

@ -1,377 +1,173 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback } from "react"; import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Play, Pencil, Trash2, ChevronDown, ChevronRight, Clock } from "lucide-react"; import { Badge } from "@/components/ui/badge";
import {
Play,
Pause,
Edit,
Trash2,
RefreshCw,
Clock,
Database,
Calendar,
Activity,
Settings
} from "lucide-react";
import { BatchConfig } from "@/lib/api/batch"; 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 (
<div
className={cn(
"mx-auto h-2 w-2 rounded-full",
status === "on" && "bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.3)]",
status === "run" &&
"bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.3)] animate-pulse",
status === "off" && "bg-muted-foreground",
status === "err" && "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.3)]"
)}
/>
);
}
/** 스파크라인 24바 (div 기반, 높이 24px) */
function SparklineBars({ slots }: { slots: SparklineSlot[] }) {
if (!slots || slots.length === 0) {
return (
<div className="flex h-6 items-end gap-[1px]">
{Array.from({ length: 24 }).map((_, i) => (
<div
key={i}
className="min-w-[3px] flex-1 rounded-t-[1px] bg-muted-foreground opacity-15"
style={{ height: "5%" }}
/>
))}
</div>
);
}
return (
<div className="flex h-6 items-end gap-[1px]">
{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 (
<div
key={i}
className={cn(
"min-w-[3px] flex-1 rounded-t-[1px] transition-opacity hover:opacity-100",
!hasRun && "bg-muted-foreground opacity-15",
hasRun && isFail && "bg-red-500 opacity-80",
hasRun && !isFail && "bg-green-500 opacity-60"
)}
style={{ height: `${height}%` }}
/>
);
})}
</div>
);
}
interface BatchCardProps { interface BatchCardProps {
batch: BatchConfig; batch: BatchConfig;
expanded: boolean;
onToggleExpand: () => void;
executingBatch: number | null; executingBatch: number | null;
onExecute: (batchId: number) => void; onExecute: (batchId: number) => void;
onToggleStatus: (batchId: number, currentStatus: string) => void; onToggleStatus: (batchId: number, currentStatus: string) => void;
onEdit: (batchId: number) => void; onEdit: (batchId: number) => void;
onDelete: (batchId: number, batchName: string) => void; onDelete: (batchId: number, batchName: string) => void;
cronToKorean: (cron: string) => string; getMappingSummary: (mappings: any[]) => string;
} }
export default function BatchCard({ export default function BatchCard({
batch, batch,
expanded,
onToggleExpand,
executingBatch, executingBatch,
onExecute, onExecute,
onToggleStatus, onToggleStatus,
onEdit, onEdit,
onDelete, onDelete,
cronToKorean, getMappingSummary
}: BatchCardProps) { }: BatchCardProps) {
const [sparkline, setSparkline] = useState<SparklineSlot[]>([]); // 상태에 따른 스타일 결정
const [recentLogs, setRecentLogs] = useState<BatchRecentLog[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
const isExecuting = executingBatch === batch.id; const isExecuting = executingBatch === batch.id;
const isActive = batch.is_active === "Y"; 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 ( return (
<> <Card className="rounded-lg border bg-card shadow-sm transition-colors hover:bg-muted/50">
<tr <CardContent className="p-4">
role="button" {/* 헤더 */}
tabIndex={0} <div className="mb-4 flex items-start justify-between">
onClick={handleRowClick} <div className="flex-1 min-w-0">
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onToggleExpand()} <div className="flex items-center gap-2 mb-1">
className={cn( <Settings className="h-4 w-4 text-muted-foreground flex-shrink-0" />
"min-h-[60px] border-b transition-colors hover:bg-card/80", <h3 className="text-base font-semibold truncate">{batch.batch_name}</h3>
expanded && "bg-primary/5 shadow-[inset_3px_0_0_0_hsl(var(--primary))]" </div>
)} <p className="mt-1 text-sm text-muted-foreground line-clamp-2">
> {batch.description || '설명 없음'}
<td className="w-[44px] border-b px-2 py-2 align-middle">
<div className="flex items-center justify-center gap-0.5">
{expanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<BatchLED status={ledStatus} />
</div>
</td>
<td className="border-b px-3 py-2 align-middle">
<div className="min-w-0">
<p className={cn("truncate text-[13px] font-bold", !isActive && "text-muted-foreground")}>
{batch.batch_name}
</p> </p>
<p className="truncate text-[10px] text-muted-foreground">{batch.description || "설명 없음"}</p>
</div> </div>
</td> <Badge variant={isActive ? 'default' : 'secondary'} className="ml-2 flex-shrink-0">
<td className="w-[100px] border-b px-2 py-2 align-middle"> {isExecuting ? '실행 중' : isActive ? '활성' : '비활성'}
<span className={cn("inline-flex rounded-[5px] px-2 py-0.5 text-[10px] font-bold", typeBadgeClass)}> </Badge>
{typeLabel} </div>
</span>
</td> {/* 정보 */}
<td className="w-[130px] border-b px-2 py-2 align-middle"> <div className="space-y-2 border-t pt-4">
<p className={cn("font-mono text-[11px] font-medium", !isActive && "text-muted-foreground")}> {/* 스케줄 정보 */}
{batch.cron_schedule} <div className="flex justify-between text-sm">
</p> <span className="flex items-center gap-2 text-muted-foreground">
<p className="text-[9px] text-muted-foreground">{cronToKorean(batch.cron_schedule)}</p> <Clock className="h-4 w-4" />
</td>
<td className="w-[160px] border-b px-2 py-2 align-middle"> </span>
{expanded ? ( <span className="font-medium truncate ml-2">{batch.cron_schedule}</span>
<SparklineBars slots={sparkline} /> </div>
) : (
<div className="flex h-6 items-end gap-[1px]"> {/* 생성일 정보 */}
{Array.from({ length: 24 }).map((_, i) => ( <div className="flex justify-between text-sm">
<div <span className="flex items-center gap-2 text-muted-foreground">
key={i} <Calendar className="h-4 w-4" />
className="min-w-[3px] flex-1 rounded-t-[1px] bg-muted-foreground opacity-15"
style={{ height: "5%" }} </span>
/> <span className="font-medium">
))} {new Date(batch.created_date).toLocaleDateString('ko-KR')}
</span>
</div>
{/* 매핑 정보 */}
{batch.batch_mappings && batch.batch_mappings.length > 0 && (
<div className="flex justify-between text-sm">
<span className="flex items-center gap-2 text-muted-foreground">
<Database className="h-4 w-4" />
</span>
<span className="font-medium">
{batch.batch_mappings.length}
</span>
</div> </div>
)} )}
</td> </div>
<td className="w-[100px] border-b px-2 py-2 align-middle">
<p className={cn("font-mono text-[10px]", isExecuting && "text-amber-600")}>{lastRunText}</p> {/* 실행 중 프로그레스 */}
<p className="text-[9px] text-muted-foreground">{lastRunSub}</p> {isExecuting && (
</td> <div className="mt-4 space-y-2 border-t pt-4">
<td className="w-[120px] border-b px-2 py-2 align-middle text-right"> <div className="flex items-center gap-2 text-sm text-primary">
<div className="flex justify-end gap-1" onClick={(e) => e.stopPropagation()}> <Activity className="h-4 w-4 animate-pulse" />
<Button <span> ...</span>
variant="ghost"
size="icon"
className="h-7 w-7 hover:bg-green-500/20 hover:text-green-600"
onClick={() => batch.id != null && onExecute(batch.id)}
disabled={isExecuting}
aria-label="수동 실행"
>
<Play className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => batch.id != null && onEdit(batch.id)}
aria-label="편집"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:bg-destructive/20 hover:text-destructive"
onClick={() => batch.id != null && onDelete(batch.id, batch.batch_name)}
aria-label="삭제"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
{expanded && (
<tr className="bg-muted/30">
<td colSpan={7} className="border-b px-6 py-5 align-top">
<div className="rounded-b-xl border border-t-0">
{detailLoading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground"> ...</div>
) : (
<div className="space-y-4">
<div
className="rounded-lg border border-primary/10 bg-gradient-to-br from-primary/[0.06] to-cyan-500/5 px-4 py-3"
style={{
background: "linear-gradient(135deg, hsl(var(--primary) / 0.06), hsl(186 100% 50% / 0.04)",
}}
>
<p className="text-[11px] leading-relaxed text-muted-foreground">
{batch.description || batch.batch_name} . : {cronToKorean(batch.cron_schedule)}
{recentLogs.length > 0 &&
` · 최근 실행 ${recentLogs.length}`}
</p>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-[11px] font-bold text-muted-foreground">
<Clock className="h-3 w-3" />
24
</h4>
<div className="h-10 w-full">
<SparklineBars slots={sparkline} />
</div>
</div>
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-[11px] font-bold text-muted-foreground">
<Clock className="h-3 w-3" />
( 5)
</h4>
<div className="overflow-x-auto rounded-md border bg-card">
<table className="w-full text-[10px]">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-2 py-1.5 text-left font-semibold"></th>
<th className="px-2 py-1.5 text-left font-semibold"></th>
<th className="px-2 py-1.5 text-right font-semibold"></th>
<th className="max-w-[120px] truncate px-2 py-1.5 text-left font-semibold"></th>
</tr>
</thead>
<tbody>
{recentLogs.length === 0 ? (
<tr>
<td colSpan={4} className="px-2 py-3 text-center text-muted-foreground">
</td>
</tr>
) : (
recentLogs.map((log, i) => (
<tr key={i} className="border-b last:border-0">
<td className="whitespace-nowrap px-2 py-1.5 font-mono">
{log.started_at
? new Date(log.started_at).toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "-"}
</td>
<td className="px-2 py-1.5">
<span
className={cn(
"rounded px-1.5 py-0.5 text-[9px] font-bold",
log.status === "SUCCESS" || log.status === "success"
? "bg-green-500/10 text-green-600"
: "bg-red-500/10 text-red-600"
)}
>
{log.status === "SUCCESS" || log.status === "success" ? "성공" : "실패"}
</span>
</td>
<td className="px-2 py-1.5 text-right font-mono">
{log.success_records ?? log.total_records ?? 0}
{log.duration_ms != null ? ` / ${(log.duration_ms / 1000).toFixed(1)}s` : ""}
</td>
<td className="max-w-[120px] truncate px-2 py-1.5 text-muted-foreground">
{log.error_message || "-"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</div> </div>
</td> <div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
</tr> <div
)} className="h-full animate-pulse rounded-full bg-primary"
</> style={{ width: '45%' }}
/>
</div>
</div>
)}
{/* 액션 버튼 */}
<div className="mt-4 grid grid-cols-2 gap-2 border-t pt-4">
{/* 실행 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => onExecute(batch.id)}
disabled={isExecuting}
className="h-9 flex-1 gap-2 text-sm"
>
{isExecuting ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
{/* 활성화/비활성화 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => onToggleStatus(batch.id, batch.is_active)}
className="h-9 flex-1 gap-2 text-sm"
>
{isActive ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
{isActive ? '비활성' : '활성'}
</Button>
{/* 수정 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => onEdit(batch.id)}
className="h-9 flex-1 gap-2 text-sm"
>
<Edit className="h-4 w-4" />
</Button>
{/* 삭제 버튼 */}
<Button
variant="destructive"
size="sm"
onClick={() => onDelete(batch.id, batch.batch_name)}
className="h-9 flex-1 gap-2 text-sm"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
); );
} }