[agent-pipeline] pipe-20260318044621-56k5 round-8

This commit is contained in:
DDD1542 2026-03-18 14:37:57 +09:00
parent 7f33b3fd8b
commit cbb8b24e70
2 changed files with 675 additions and 407 deletions

View File

@ -1,57 +1,102 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useCallback, useMemo } 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 { import { BatchAPI, type BatchConfig } from "@/lib/api/batch";
BatchAPI, import apiClient from "@/lib/api/client";
BatchConfig,
BatchMapping,
} from "@/lib/api/batch";
import BatchCard from "@/components/admin/BatchCard";
import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScrollToTop } from "@/components/common/ScrollToTop";
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";
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 [currentPage, setCurrentPage] = useState(1); const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [totalPages, setTotalPages] = useState(1); const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
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({
page: currentPage, limit: 500,
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([]);
} }
@ -62,20 +107,46 @@ 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(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`); toast.success(
`배치가 성공적으로 실행되었습니다! (처리: ${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: "배치 설정을 확인하고 다시 시도해 주세요.",
}); });
@ -84,226 +155,230 @@ 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";
console.log("📝 새로운 상태:", newStatus); await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus === "Y" });
toast.success(`배치가 ${newStatus === "Y" ? "활성화" : "비활성화"}되었습니다.`);
const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus }); loadBatchConfigs();
console.log("✅ API 호출 성공:", result); loadStats();
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}' 배치를 삭제하시겠습니까?`)) { if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) return;
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) => {
setSearchTerm(value);
setCurrentPage(1); // 검색 시 첫 페이지로 이동
};
// 매핑 정보 요약 생성 const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db") => {
const getMappingSummary = (mappings: BatchMapping[]) => {
if (!mappings || mappings.length === 0) {
return "매핑 없음";
}
const tableGroups = new Map<string, number>();
mappings.forEach(mapping => {
const key = `${mapping.from_table_name}${mapping.to_table_name}`;
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); setIsBatchTypeModalOpen(false);
if (type === "db-to-db") {
if (type === 'db-to-db') { router.push("/admin/batchmng/create");
// 기존 DB → DB 배치 생성 페이지로 이동 } else {
console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create'); router.push("/admin/batch-management-new");
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';
}
} }
}; };
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]);
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="space-y-2 border-b pb-4"> <div className="flex flex-col gap-4 border-b pb-4 sm:flex-row sm:items-end sm:justify-between">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <div className="space-y-1">
<p className="text-sm text-muted-foreground"> .</p> <h1 className="text-xl font-extrabold tracking-tight sm:text-3xl"> </h1>
</div> <p className="text-xs text-muted-foreground sm:text-sm">
.
{/* 검색 및 액션 영역 */} </p>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> </div>
{/* 검색 영역 */} <div className="flex gap-2">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center"> <Button variant="outline" size="default" onClick={loadBatchConfigs} disabled={loading} className="gap-2">
<div className="w-full sm:w-[400px]"> <RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
<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>
</div> <Button size="default" onClick={handleCreateBatch} className="gap-2">
{/* 액션 버튼 영역 */}
<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개 */}
{batchConfigs.length === 0 ? ( <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm"> <div className="flex justify-between rounded-xl border bg-card p-4">
<div className="flex flex-col items-center gap-4 text-center"> <div>
<Database className="h-12 w-12 text-muted-foreground" /> <p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> </p>
<div className="space-y-2"> <p className="mt-1 font-mono text-2xl font-extrabold sm:text-3xl">
<h3 className="text-lg font-semibold"> </h3> {statsLoading ? "-" : (stats?.totalBatches ?? 0).toLocaleString()}
<p className="text-sm text-muted-foreground"> </p>
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
</p>
</div>
{!searchTerm && (
<Button
onClick={handleCreateBatch}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
</Button>
)}
</div> </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" />
</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" ? "활성" : "비활성"}
</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>
))}
</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="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"> <div className="overflow-hidden rounded-xl border bg-card">
{batchConfigs.map((batch) => ( <table className="w-full table-fixed border-collapse" style={{ tableLayout: "fixed" }}>
<BatchCard <thead>
key={batch.id} <tr className="h-10 bg-muted/50">
batch={batch} <th className="w-[44px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground" />
executingBatch={executingBatch} <th className="border-b px-3 text-left text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
onExecute={executeBatch}
onToggleStatus={(batchId, currentStatus) => { </th>
toggleBatchStatus(batchId, currentStatus); <th className="w-[100px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
}}
onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)} </th>
onDelete={deleteBatch} <th className="w-[130px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
getMappingSummary={getMappingSummary}
/> </th>
))} <th className="w-[160px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
</div> 24h
)} </th>
<th className="w-[100px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{/* 페이지네이션 */}
{totalPages > 1 && ( </th>
<div className="flex items-center justify-center gap-2"> <th className="w-[120px] border-b px-2 text-right text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<Button
variant="outline" </th>
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))} </tr>
disabled={currentPage === 1} </thead>
className="h-10 text-sm font-medium" <tbody>
> {filteredBatches.map((batch) => (
<BatchCard
</Button> key={batch.id}
batch={batch}
<div className="flex items-center gap-1"> expanded={expandedId === batch.id}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => { onToggleExpand={() => setExpandedId((id) => (id === batch.id ? null : batch.id ?? null))}
const pageNum = i + 1; executingBatch={executingBatch}
return ( onExecute={executeBatch}
<Button onToggleStatus={toggleBatchStatus}
key={pageNum} onEdit={(id) => router.push(`/admin/batchmng/edit/${id}`)}
variant={currentPage === pageNum ? "default" : "outline"} onDelete={deleteBatch}
onClick={() => setCurrentPage(pageNum)} cronToKorean={cronToKorean}
className="h-10 min-w-[40px] text-sm" />
> ))}
{pageNum} </tbody>
</Button> </table>
);
})}
</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>
)} )}
@ -311,60 +386,49 @@ 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">
<div className="space-y-6"> <h2 className="text-center text-xl font-semibold"> </h2>
<h2 className="text-xl font-semibold text-center"> </h2> <div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
<button
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> type="button"
{/* DB → DB */} 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"
<button onClick={() => handleBatchTypeSelect("db-to-db")}
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" />
<div className="flex items-center gap-2"> <span className="text-muted-foreground"></span>
<Database className="h-8 w-8 text-primary" /> <Database className="h-8 w-8 text-primary" />
<span className="text-muted-foreground"></span> </div>
<Database className="h-8 w-8 text-primary" /> <div className="space-y-1 text-center">
</div> <div className="text-lg font-medium">DB DB</div>
<div className="space-y-1 text-center"> <div className="text-sm text-muted-foreground"> </div>
<div className="text-lg font-medium">DB DB</div> </div>
<div className="text-sm text-muted-foreground"> </div> </button>
</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"
{/* REST API → DB */} onClick={() => handleBatchTypeSelect("restapi-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('restapi-to-db')} <span className="text-2xl text-muted-foreground">API</span>
> <span className="text-muted-foreground"></span>
<div className="flex items-center gap-2"> <Database className="h-8 w-8 text-primary" />
<span className="text-2xl">🌐</span> </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">REST API DB</div>
</div> <div className="text-sm text-muted-foreground">REST API에서 DB로 </div>
<div className="space-y-1 text-center"> </div>
<div className="text-lg font-medium">REST API DB</div> </button>
<div className="text-sm text-muted-foreground">REST API에서 </div> </div>
</div> <div className="mt-6 flex justify-center">
</button> <Button variant="outline" onClick={() => setIsBatchTypeModalOpen(false)}>
</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,173 +1,377 @@
"use client"; "use client";
import React from "react"; import React, { useState, useEffect, useCallback } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Play, Pencil, Trash2, ChevronDown, ChevronRight, Clock } from "lucide-react";
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;
getMappingSummary: (mappings: any[]) => string; cronToKorean: (cron: string) => string;
} }
export default function BatchCard({ export default function BatchCard({
batch, batch,
expanded,
onToggleExpand,
executingBatch, executingBatch,
onExecute, onExecute,
onToggleStatus, onToggleStatus,
onEdit, onEdit,
onDelete, onDelete,
getMappingSummary cronToKorean,
}: 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"> <>
<CardContent className="p-4"> <tr
{/* 헤더 */} role="button"
<div className="mb-4 flex items-start justify-between"> tabIndex={0}
<div className="flex-1 min-w-0"> onClick={handleRowClick}
<div className="flex items-center gap-2 mb-1"> onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onToggleExpand()}
<Settings className="h-4 w-4 text-muted-foreground flex-shrink-0" /> className={cn(
<h3 className="text-base font-semibold truncate">{batch.batch_name}</h3> "min-h-[60px] border-b transition-colors hover:bg-card/80",
</div> expanded && "bg-primary/5 shadow-[inset_3px_0_0_0_hsl(var(--primary))]"
<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>
<Badge variant={isActive ? 'default' : 'secondary'} className="ml-2 flex-shrink-0"> </td>
{isExecuting ? '실행 중' : isActive ? '활성' : '비활성'} <td className="w-[100px] border-b px-2 py-2 align-middle">
</Badge> <span className={cn("inline-flex rounded-[5px] px-2 py-0.5 text-[10px] font-bold", typeBadgeClass)}>
</div> {typeLabel}
</span>
{/* 정보 */} </td>
<div className="space-y-2 border-t pt-4"> <td className="w-[130px] border-b px-2 py-2 align-middle">
{/* 스케줄 정보 */} <p className={cn("font-mono text-[11px] font-medium", !isActive && "text-muted-foreground")}>
<div className="flex justify-between text-sm"> {batch.cron_schedule}
<span className="flex items-center gap-2 text-muted-foreground"> </p>
<Clock className="h-4 w-4" /> <p className="text-[9px] text-muted-foreground">{cronToKorean(batch.cron_schedule)}</p>
</td>
</span> <td className="w-[160px] border-b px-2 py-2 align-middle">
<span className="font-medium truncate ml-2">{batch.cron_schedule}</span> {expanded ? (
</div> <SparklineBars slots={sparkline} />
) : (
{/* 생성일 정보 */} <div className="flex h-6 items-end gap-[1px]">
<div className="flex justify-between text-sm"> {Array.from({ length: 24 }).map((_, i) => (
<span className="flex items-center gap-2 text-muted-foreground"> <div
<Calendar className="h-4 w-4" /> key={i}
className="min-w-[3px] flex-1 rounded-t-[1px] bg-muted-foreground opacity-15"
</span> style={{ height: "5%" }}
<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>
)} )}
</div> </td>
<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>
{isExecuting && ( <p className="text-[9px] text-muted-foreground">{lastRunSub}</p>
<div className="mt-4 space-y-2 border-t pt-4"> </td>
<div className="flex items-center gap-2 text-sm text-primary"> <td className="w-[120px] border-b px-2 py-2 align-middle text-right">
<Activity className="h-4 w-4 animate-pulse" /> <div className="flex justify-end gap-1" onClick={(e) => e.stopPropagation()}>
<span> ...</span> <Button
</div> variant="ghost"
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary"> size="icon"
<div className="h-7 w-7 hover:bg-green-500/20 hover:text-green-600"
className="h-full animate-pulse rounded-full bg-primary" onClick={() => batch.id != null && onExecute(batch.id)}
style={{ width: '45%' }} disabled={isExecuting}
/> aria-label="수동 실행"
</div> >
<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> </div>
)} </td>
</tr>
{/* 액션 버튼 */} {expanded && (
<div className="mt-4 grid grid-cols-2 gap-2 border-t pt-4"> <tr className="bg-muted/30">
{/* 실행 버튼 */} <td colSpan={7} className="border-b px-6 py-5 align-top">
<Button <div className="rounded-b-xl border border-t-0">
variant="outline" {detailLoading ? (
size="sm" <div className="flex items-center justify-center py-8 text-sm text-muted-foreground"> ...</div>
onClick={() => onExecute(batch.id)} ) : (
disabled={isExecuting} <div className="space-y-4">
className="h-9 flex-1 gap-2 text-sm" <div
> className="rounded-lg border border-primary/10 bg-gradient-to-br from-primary/[0.06] to-cyan-500/5 px-4 py-3"
{isExecuting ? ( style={{
<RefreshCw className="h-4 w-4 animate-spin" /> background: "linear-gradient(135deg, hsl(var(--primary) / 0.06), hsl(186 100% 50% / 0.04)",
) : ( }}
<Play className="h-4 w-4" /> >
)} <p className="text-[11px] leading-relaxed text-muted-foreground">
{batch.description || batch.batch_name} . : {cronToKorean(batch.cron_schedule)}
</Button> {recentLogs.length > 0 &&
` · 최근 실행 ${recentLogs.length}`}
{/* 활성화/비활성화 버튼 */} </p>
<Button </div>
variant="outline" <div className="grid gap-5 sm:grid-cols-2">
size="sm" <div>
onClick={() => onToggleStatus(batch.id, batch.is_active)} <h4 className="mb-2 flex items-center gap-1.5 text-[11px] font-bold text-muted-foreground">
className="h-9 flex-1 gap-2 text-sm" <Clock className="h-3 w-3" />
> 24
{isActive ? ( </h4>
<Pause className="h-4 w-4" /> <div className="h-10 w-full">
) : ( <SparklineBars slots={sparkline} />
<Play className="h-4 w-4" /> </div>
)} </div>
{isActive ? '비활성' : '활성'} <div>
</Button> <h4 className="mb-2 flex items-center gap-1.5 text-[11px] font-bold text-muted-foreground">
<Clock className="h-3 w-3" />
{/* 수정 버튼 */} ( 5)
<Button </h4>
variant="outline" <div className="overflow-x-auto rounded-md border bg-card">
size="sm" <table className="w-full text-[10px]">
onClick={() => onEdit(batch.id)} <thead>
className="h-9 flex-1 gap-2 text-sm" <tr className="border-b bg-muted/50">
> <th className="px-2 py-1.5 text-left font-semibold"></th>
<Edit className="h-4 w-4" /> <th className="px-2 py-1.5 text-left font-semibold"></th>
<th className="px-2 py-1.5 text-right font-semibold"></th>
</Button> <th className="max-w-[120px] truncate px-2 py-1.5 text-left font-semibold"></th>
</tr>
{/* 삭제 버튼 */} </thead>
<Button <tbody>
variant="destructive" {recentLogs.length === 0 ? (
size="sm" <tr>
onClick={() => onDelete(batch.id, batch.batch_name)} <td colSpan={4} className="px-2 py-3 text-center text-muted-foreground">
className="h-9 flex-1 gap-2 text-sm"
> </td>
<Trash2 className="h-4 w-4" /> </tr>
) : (
</Button> recentLogs.map((log, i) => (
</div> <tr key={i} className="border-b last:border-0">
</CardContent> <td className="whitespace-nowrap px-2 py-1.5 font-mono">
</Card> {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>
</td>
</tr>
)}
</>
); );
} }