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

435 lines
18 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Plus,
Search,
RefreshCw,
Database,
LayoutGrid,
CheckCircle,
Activity,
AlertTriangle,
} from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { useRouter } from "next/navigation";
import { BatchAPI, type BatchConfig } from "@/lib/api/batch";
import apiClient from "@/lib/api/client";
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() {
const router = useRouter();
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<BatchStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
const loadBatchConfigs = useCallback(async () => {
setLoading(true);
try {
const response = await BatchAPI.getBatchConfigs({
limit: 500,
search: searchTerm || undefined,
});
if (response.success && response.data) {
setBatchConfigs(response.data);
} else {
setBatchConfigs([]);
}
} catch (error) {
console.error("배치 목록 조회 실패:", error);
toast.error("배치 목록을 불러오는데 실패했습니다.");
setBatchConfigs([]);
} finally {
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) => {
setExecutingBatch(batchId);
try {
const response = await BatchAPI.executeBatchConfig(batchId);
if (response.success) {
toast.success(
`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords ?? 0}개, 성공: ${response.data?.successRecords ?? 0}개)`
);
loadBatchConfigs();
loadStats();
} else {
toast.error("배치 실행에 실패했습니다.");
}
} catch (error) {
showErrorToast("배치 실행에 실패했습니다", error, {
guidance: "배치 설정을 확인하고 다시 시도해 주세요.",
});
} finally {
setExecutingBatch(null);
}
};
const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
try {
const newStatus = currentStatus === "Y" ? "N" : "Y";
await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus === "Y" });
toast.success(`배치가 ${newStatus === "Y" ? "활성화" : "비활성화"}되었습니다.`);
loadBatchConfigs();
loadStats();
} catch (error) {
console.error("배치 상태 변경 실패:", error);
toast.error("배치 상태 변경에 실패했습니다.");
}
};
const deleteBatch = async (batchId: number, batchName: string) => {
if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) return;
try {
await BatchAPI.deleteBatchConfig(batchId);
toast.success("배치가 삭제되었습니다.");
loadBatchConfigs();
loadStats();
} catch (error) {
console.error("배치 삭제 실패:", error);
toast.error("배치 삭제에 실패했습니다.");
}
};
const handleCreateBatch = () => setIsBatchTypeModalOpen(true);
const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db") => {
setIsBatchTypeModalOpen(false);
if (type === "db-to-db") {
router.push("/admin/batchmng/create");
} else {
router.push("/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 (
<div className="flex min-h-screen flex-col bg-background">
<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-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")} />
</Button>
<Button size="default" onClick={handleCreateBatch} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* 통계 카드 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" />
</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 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>
</div>
)}
{/* 배치 타입 선택 모달 */}
{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">
<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>
</div>
</div>
</div>
)}
</div>
<ScrollToTop />
</div>
);
}