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'; import GuideModal, { HelpButton } from '../components/GuideModal'; const POLLING_INTERVAL = 30000; const JOBS_GUIDE = [ { title: '상태 필터', content: '상단의 탭 버튼으로 작업 상태별 필터링이 가능합니다.\n전체 / 실행 중 / 성공 / 실패 / 미실행 중 선택하세요.\n각 탭 옆의 숫자는 해당 상태의 작업 수입니다.', }, { title: '검색 및 정렬', content: '검색창에 작업명을 입력하면 실시간으로 필터링됩니다.\n정렬 옵션: 작업명순, 최신 실행순(기본), 상태별(실패 우선)\n테이블/카드 뷰 전환 버튼으로 보기 방식을 변경할 수 있습니다.', }, { title: '작업 실행', content: '"실행" 버튼을 클릭하면 확인 팝업이 표시됩니다.\n확인 후 해당 배치 작업이 즉시 실행됩니다.\n실행 중인 작업은 좌측에 초록색 점이 표시됩니다.', }, { title: '이력 보기', content: '"이력 보기" 버튼을 클릭하면 해당 작업의 실행 이력 화면으로 이동합니다.\n과거 실행 결과, 소요 시간 등을 상세히 확인할 수 있습니다.', }, ]; 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'); const [guideOpen, setGuideOpen] = useState(false); // 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 */}

배치 작업 목록

setGuideOpen(true)} />
총 {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 ? ( 자동 ) : ( 수동 )}
)} setGuideOpen(false)} /> {/* Execute Modal (custom with date params) */} {executeModalOpen && (
setExecuteModalOpen(false)} >
e.stopPropagation()} >

작업 실행 확인

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

)}
); }