import { useState, useCallback, useRef, useEffect } from 'react'; import { batchApi, type ExecutionInfo, type JobExecutionDto, type PeriodInfo, type ScheduleTimeline } from '../api/batchApi'; import { formatDateTime, calculateDuration } from '../utils/formatters'; import { usePoller } from '../hooks/usePoller'; import { useToastContext } from '../contexts/ToastContext'; import { getStatusColor } from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge'; import LoadingSpinner from '../components/LoadingSpinner'; import EmptyState from '../components/EmptyState'; type ViewType = 'day' | 'week' | 'month'; interface TooltipData { jobName: string; period: PeriodInfo; execution: ExecutionInfo; x: number; y: number; } interface SelectedCell { jobName: string; periodKey: string; periodLabel: string; } const VIEW_OPTIONS: { value: ViewType; label: string }[] = [ { value: 'day', label: 'Day' }, { value: 'week', label: 'Week' }, { value: 'month', label: 'Month' }, ]; const LEGEND_ITEMS = [ { status: 'COMPLETED', color: '#10b981', label: '완료' }, { status: 'FAILED', color: '#ef4444', label: '실패' }, { status: 'STARTED', color: '#3b82f6', label: '실행중' }, { status: 'SCHEDULED', color: '#8b5cf6', label: '예정' }, { status: 'NONE', color: '#e5e7eb', label: '없음' }, ]; const JOB_COL_WIDTH = 200; const CELL_MIN_WIDTH = 80; const POLLING_INTERVAL = 30000; function formatDateStr(date: Date): string { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } function shiftDate(date: Date, view: ViewType, delta: number): Date { const next = new Date(date); switch (view) { case 'day': next.setDate(next.getDate() + delta); break; case 'week': next.setDate(next.getDate() + delta * 7); break; case 'month': next.setMonth(next.getMonth() + delta); break; } return next; } function isRunning(status: string): boolean { return status === 'STARTED' || status === 'STARTING'; } export default function Timeline() { const { showToast } = useToastContext(); const [view, setView] = useState('day'); const [currentDate, setCurrentDate] = useState(() => new Date()); const [periodLabel, setPeriodLabel] = useState(''); const [periods, setPeriods] = useState([]); const [schedules, setSchedules] = useState([]); const [loading, setLoading] = useState(true); // Tooltip const [tooltip, setTooltip] = useState(null); const tooltipTimeoutRef = useRef | null>(null); // Selected cell & detail panel const [selectedCell, setSelectedCell] = useState(null); const [detailExecutions, setDetailExecutions] = useState([]); const [detailLoading, setDetailLoading] = useState(false); const loadTimeline = useCallback(async () => { try { const dateStr = formatDateStr(currentDate); const result = await batchApi.getTimeline(view, dateStr); setPeriodLabel(result.periodLabel); setPeriods(result.periods); setSchedules(result.schedules); } catch (err) { showToast('타임라인 조회 실패', 'error'); console.error(err); } finally { setLoading(false); } }, [view, currentDate, showToast]); usePoller(loadTimeline, POLLING_INTERVAL, [view, currentDate]); const handlePrev = () => setCurrentDate((d) => shiftDate(d, view, -1)); const handleNext = () => setCurrentDate((d) => shiftDate(d, view, 1)); const handleToday = () => setCurrentDate(new Date()); const handleRefresh = async () => { setLoading(true); await loadTimeline(); }; // Tooltip handlers const handleCellMouseEnter = ( e: React.MouseEvent, jobName: string, period: PeriodInfo, execution: ExecutionInfo, ) => { if (tooltipTimeoutRef.current) { clearTimeout(tooltipTimeoutRef.current); } const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setTooltip({ jobName, period, execution, x: rect.left + rect.width / 2, y: rect.top, }); }; const handleCellMouseLeave = () => { tooltipTimeoutRef.current = setTimeout(() => { setTooltip(null); }, 100); }; // Clean up tooltip timeout useEffect(() => { return () => { if (tooltipTimeoutRef.current) { clearTimeout(tooltipTimeoutRef.current); } }; }, []); // Cell click -> detail panel const handleCellClick = async (jobName: string, periodKey: string, periodLabel: string) => { // Toggle off if clicking same cell if (selectedCell?.jobName === jobName && selectedCell?.periodKey === periodKey) { setSelectedCell(null); setDetailExecutions([]); return; } setSelectedCell({ jobName, periodKey, periodLabel }); setDetailLoading(true); setDetailExecutions([]); try { const executions = await batchApi.getPeriodExecutions(jobName, view, periodKey); setDetailExecutions(executions); } catch (err) { showToast('구간 실행 이력 조회 실패', 'error'); console.error(err); } finally { setDetailLoading(false); } }; const closeDetail = () => { setSelectedCell(null); setDetailExecutions([]); }; const gridTemplateColumns = `${JOB_COL_WIDTH}px repeat(${periods.length}, minmax(${CELL_MIN_WIDTH}px, 1fr))`; return (
{/* Controls */}
{/* View Toggle */}
{VIEW_OPTIONS.map((opt) => ( ))}
{/* Navigation */}
{/* Period Label */} {periodLabel} {/* Refresh */}
{/* Legend */}
{LEGEND_ITEMS.map((item) => (
{item.label}
))}
{/* Timeline Grid */}
{loading ? ( ) : schedules.length === 0 ? ( ) : (
{/* Header Row */}
작업명
{periods.map((period) => (
{period.label}
))} {/* Data Rows */} {schedules.map((schedule) => ( <> {/* Job Name (sticky) */}
{schedule.jobName}
{/* Execution Cells */} {periods.map((period) => { const exec = schedule.executions[period.key]; const hasExec = exec !== null && exec !== undefined; const isSelected = selectedCell?.jobName === schedule.jobName && selectedCell?.periodKey === period.key; const running = hasExec && isRunning(exec.status); return (
handleCellClick(schedule.jobName, period.key, period.label) } onMouseEnter={ hasExec ? (e) => handleCellMouseEnter(e, schedule.jobName, period, exec) : undefined } onMouseLeave={hasExec ? handleCellMouseLeave : undefined} > {hasExec && (
)}
); })} ))}
)}
{/* Tooltip */} {tooltip && (
{tooltip.jobName}
기간: {tooltip.period.label}
상태:{' '} {tooltip.execution.status}
{tooltip.execution.startTime && (
시작: {formatDateTime(tooltip.execution.startTime)}
)} {tooltip.execution.endTime && (
종료: {formatDateTime(tooltip.execution.endTime)}
)} {tooltip.execution.executionId && (
실행 ID: {tooltip.execution.executionId}
)}
{/* Arrow */}
)} {/* Detail Panel */} {selectedCell && (

{selectedCell.jobName}

구간: {selectedCell.periodLabel}

{detailLoading ? ( ) : detailExecutions.length === 0 ? ( ) : (
{detailExecutions.map((exec) => ( ))}
실행 ID 상태 시작 시간 종료 시간 소요 시간 상세
#{exec.executionId} {formatDateTime(exec.startTime)} {formatDateTime(exec.endTime)} {calculateDuration(exec.startTime, exec.endTime)} 상세
)}
)}
); }