ERP-node/frontend/components/admin/BatchCard.tsx

378 lines
15 KiB
TypeScript
Raw Normal View History

2025-09-30 10:30:26 +09:00
"use client";
import React, { useState, useEffect, useCallback } from "react";
2025-09-30 10:30:26 +09:00
import { Button } from "@/components/ui/button";
import { Play, Pencil, Trash2, ChevronDown, ChevronRight, Clock } from "lucide-react";
2025-09-30 10:30:26 +09:00
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>
);
}
2025-09-30 10:30:26 +09:00
interface BatchCardProps {
batch: BatchConfig;
expanded: boolean;
onToggleExpand: () => void;
2025-09-30 10:30:26 +09:00
executingBatch: number | null;
onExecute: (batchId: number) => void;
onToggleStatus: (batchId: number, currentStatus: string) => void;
onEdit: (batchId: number) => void;
onDelete: (batchId: number, batchName: string) => void;
cronToKorean: (cron: string) => string;
2025-09-30 10:30:26 +09:00
}
export default function BatchCard({
batch,
expanded,
onToggleExpand,
2025-09-30 10:30:26 +09:00
executingBatch,
onExecute,
onToggleStatus,
onEdit,
onDelete,
cronToKorean,
2025-09-30 10:30:26 +09:00
}: BatchCardProps) {
const [sparkline, setSparkline] = useState<SparklineSlot[]>([]);
const [recentLogs, setRecentLogs] = useState<BatchRecentLog[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
2025-10-22 14:52:13 +09:00
const isExecuting = executingBatch === batch.id;
const isActive = batch.is_active === "Y";
const ledStatus: LedStatus = isExecuting ? "run" : isActive ? "on" : "off";
2025-09-30 10:30:26 +09:00
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";
2025-09-30 10:30:26 +09:00
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]);
2025-09-30 10:30:26 +09:00
useEffect(() => {
if (expanded) loadDetail();
}, [expanded, loadDetail]);
2025-09-30 10:30:26 +09:00
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)}시간 전`;
})();
2025-10-22 14:52:13 +09:00
const handleRowClick = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest("button")) return;
onToggleExpand();
};
2025-09-30 10:30:26 +09:00
return (
<>
<tr
role="button"
tabIndex={0}
onClick={handleRowClick}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onToggleExpand()}
className={cn(
"min-h-[60px] border-b transition-colors hover:bg-card/80",
expanded && "bg-primary/5 shadow-[inset_3px_0_0_0_hsl(var(--primary))]"
)}
>
<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" />
2025-09-30 10:30:26 +09:00
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
2025-09-30 10:30:26 +09:00
)}
<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 className="truncate text-[10px] text-muted-foreground">{batch.description || "설명 없음"}</p>
</div>
</td>
<td className="w-[100px] border-b px-2 py-2 align-middle">
<span className={cn("inline-flex rounded-[5px] px-2 py-0.5 text-[10px] font-bold", typeBadgeClass)}>
{typeLabel}
</span>
</td>
<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")}>
{batch.cron_schedule}
</p>
<p className="text-[9px] text-muted-foreground">{cronToKorean(batch.cron_schedule)}</p>
</td>
<td className="w-[160px] border-b px-2 py-2 align-middle">
{expanded ? (
<SparklineBars slots={sparkline} />
) : (
<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>
)}
</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>
<p className="text-[9px] text-muted-foreground">{lastRunSub}</p>
</td>
<td className="w-[120px] border-b px-2 py-2 align-middle text-right">
<div className="flex justify-end gap-1" onClick={(e) => e.stopPropagation()}>
<Button
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>
</td>
</tr>
)}
</>
2025-09-30 10:30:26 +09:00
);
}