import { useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { batchApi } from '../api/batchApi'; import type { JobDetailDto } from '../api/batchApi'; import { usePoller } from '../hooks/usePoller'; import { useToastContext } from '../contexts/ToastContext'; import StatusBadge from '../components/StatusBadge'; import EmptyState from '../components/EmptyState'; import LoadingSpinner from '../components/LoadingSpinner'; import { formatDateTime, calculateDuration } from '../utils/formatters'; const POLLING_INTERVAL = 30000; type StatusFilterKey = 'ALL' | 'STARTED' | 'COMPLETED' | 'FAILED' | 'NONE'; type SortKey = 'name' | 'recent' | 'status'; type ViewMode = 'card' | 'table'; interface StatusTabConfig { key: StatusFilterKey; label: string; } const STATUS_TABS: StatusTabConfig[] = [ { key: 'ALL', label: '전체' }, { key: 'STARTED', label: '실행 중' }, { key: 'COMPLETED', label: '성공' }, { key: 'FAILED', label: '실패' }, { key: 'NONE', label: '미실행' }, ]; const STATUS_ORDER: Record = { FAILED: 0, STARTED: 1, COMPLETED: 2, }; function getStatusOrder(job: JobDetailDto): number { if (!job.lastExecution) return 3; return STATUS_ORDER[job.lastExecution.status] ?? 4; } function matchesStatusFilter(job: JobDetailDto, filter: StatusFilterKey): boolean { if (filter === 'ALL') return true; if (filter === 'NONE') return job.lastExecution === null; return job.lastExecution?.status === filter; } export default function Jobs() { const navigate = useNavigate(); const { showToast } = useToastContext(); const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState('ALL'); const [sortKey, setSortKey] = useState('recent'); const [viewMode, setViewMode] = useState('table'); // Execute modal (individual card) const [executeModalOpen, setExecuteModalOpen] = useState(false); const [targetJob, setTargetJob] = useState(''); const [executing, setExecuting] = useState(false); const loadJobs = useCallback(async () => { try { const data = await batchApi.getJobsDetail(); setJobs(data); } catch (err) { console.error('Jobs load failed:', err); } finally { setLoading(false); } }, []); usePoller(loadJobs, POLLING_INTERVAL); /** displayName 우선, 없으면 jobName */ const getJobLabel = useCallback((job: JobDetailDto) => job.displayName || job.jobName, []); const statusCounts = useMemo(() => { const searchFiltered = searchTerm.trim() ? jobs.filter((job) => { const term = searchTerm.toLowerCase(); return job.jobName.toLowerCase().includes(term) || (job.displayName?.toLowerCase().includes(term) ?? false); }) : jobs; return STATUS_TABS.reduce>( (acc, tab) => { acc[tab.key] = searchFiltered.filter((job) => matchesStatusFilter(job, tab.key)).length; return acc; }, { ALL: 0, STARTED: 0, COMPLETED: 0, FAILED: 0, NONE: 0 }, ); }, [jobs, searchTerm]); const filteredJobs = useMemo(() => { let result = jobs; if (searchTerm.trim()) { const term = searchTerm.toLowerCase(); result = result.filter((job) => job.jobName.toLowerCase().includes(term) || (job.displayName?.toLowerCase().includes(term) ?? false), ); } result = result.filter((job) => matchesStatusFilter(job, statusFilter)); result = [...result].sort((a, b) => { if (sortKey === 'name') { return getJobLabel(a).localeCompare(getJobLabel(b)); } if (sortKey === 'recent') { const aTime = a.lastExecution?.startTime ? new Date(a.lastExecution.startTime).getTime() : 0; const bTime = b.lastExecution?.startTime ? new Date(b.lastExecution.startTime).getTime() : 0; return bTime - aTime; } if (sortKey === 'status') { return getStatusOrder(a) - getStatusOrder(b); } return 0; }); return result; }, [jobs, searchTerm, statusFilter, sortKey]); const handleExecuteClick = (jobName: string) => { setTargetJob(jobName); setExecuteModalOpen(true); }; const handleConfirmExecute = async () => { if (!targetJob) return; setExecuting(true); try { const result = await batchApi.executeJob(targetJob); showToast( result.message || `${targetJob} 실행 요청 완료`, 'success', ); setExecuteModalOpen(false); } catch (err) { showToast( `실행 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`, 'error', ); } finally { setExecuting(false); } }; const handleViewHistory = (jobName: string) => { navigate(`/executions?job=${encodeURIComponent(jobName)}`); }; if (loading) return ; return (
{/* Header */}

배치 작업 목록

총 {jobs.length}개 작업
{/* Status Filter Tabs */}
{STATUS_TABS.map((tab) => ( ))}
{/* Search + Sort + View Toggle */}
{/* Search */}
setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none" /> {searchTerm && ( )}
{/* Sort dropdown */} {/* View mode toggle */}
{searchTerm && (

{filteredJobs.length}개 작업 검색됨

)}
{/* Job List */} {filteredJobs.length === 0 ? (
) : viewMode === 'card' ? ( /* Card View */
{filteredJobs.map((job) => { const isRunning = job.lastExecution?.status === 'STARTED'; const duration = job.lastExecution ? calculateDuration(job.lastExecution.startTime, job.lastExecution.endTime) : null; const showDuration = job.lastExecution?.endTime != null && duration !== null && duration !== '-'; return (

{getJobLabel(job)}

{isRunning && ( )} {job.lastExecution && ( )}
{/* Job detail info */}
{job.lastExecution ? ( <>

마지막 실행: {formatDateTime(job.lastExecution.startTime)}

{showDuration && (

소요 시간: {duration}

)} {isRunning && !showDuration && (

소요 시간: 실행 중...

)} ) : (

실행 이력 없음

)}
{job.scheduleCron ? ( 자동 ) : ( 수동 )} {job.scheduleCron && ( {job.scheduleCron} )}
); })}
) : ( /* Table View */
{filteredJobs.map((job) => { const isRunning = job.lastExecution?.status === 'STARTED'; const duration = job.lastExecution ? calculateDuration(job.lastExecution.startTime, job.lastExecution.endTime) : '-'; return ( ); })}
작업명 상태 마지막 실행 소요시간 스케줄 액션
{isRunning && ( )} {getJobLabel(job)}
{job.lastExecution ? ( ) : ( 미실행 )} {job.lastExecution ? formatDateTime(job.lastExecution.startTime) : '-'} {job.lastExecution ? duration : '-'} {job.scheduleCron ? ( 자동 ) : ( 수동 )}
)} {/* Execute Modal (custom with date params) */} {executeModalOpen && (
setExecuteModalOpen(false)} >
e.stopPropagation()} >

작업 실행 확인

"{jobs.find((j) => j.jobName === targetJob)?.displayName || targetJob}" 작업을 실행하시겠습니까?

)}
); }