diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 794d220..219cbe2 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -6,6 +6,7 @@ ### 추가 - 자동 재수집 및 재수집 프로세스 전면 개선 (#30) +- 배치 작업 목록 UX 개선: 상태 필터, 카드/테이블 뷰, 정렬, 실행 중 강조 (#33) ### 기타 - 팀 워크플로우 v1.6.1 동기화 diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b286f02..b480f04 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -46,13 +46,6 @@ export default function Dashboard() { const [dashboard, setDashboard] = useState(null); const [loading, setLoading] = useState(true); - // Execute Job modal - const [showExecuteModal, setShowExecuteModal] = useState(false); - const [jobs, setJobs] = useState([]); - const [selectedJob, setSelectedJob] = useState(''); - const [executing, setExecuting] = useState(false); - const [startDate, setStartDate] = useState(''); - const [stopDate, setStopDate] = useState(''); const [abandoning, setAbandoning] = useState(false); const [statistics, setStatistics] = useState(null); const [recollectionStats, setRecollectionStats] = useState(null); @@ -96,44 +89,6 @@ export default function Dashboard() { usePoller(loadDashboard, POLLING_INTERVAL); - const handleOpenExecuteModal = async () => { - try { - const jobList = await batchApi.getJobs(); - setJobs(jobList); - setSelectedJob(jobList[0] ?? ''); - setShowExecuteModal(true); - } catch (err) { - showToast('작업 목록을 불러올 수 없습니다.', 'error'); - console.error(err); - } - }; - - const handleExecuteJob = async () => { - if (!selectedJob) return; - setExecuting(true); - try { - const params: Record = {}; - if (startDate) params.startDate = startDate; - if (stopDate) params.stopDate = stopDate; - const result = await batchApi.executeJob( - selectedJob, - Object.keys(params).length > 0 ? params : undefined, - ); - showToast( - result.message || `${selectedJob} 실행 요청 완료`, - 'success', - ); - setShowExecuteModal(false); - setStartDate(''); - setStopDate(''); - await loadDashboard(); - } catch (err) { - showToast(`실행 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`, 'error'); - } finally { - setExecuting(false); - } - }; - const handleAbandonAllStale = async () => { setAbandoning(true); try { @@ -173,13 +128,6 @@ export default function Dashboard() { {/* Header */}

대시보드

-
{/* F1: Stale Execution Warning Banner */} @@ -517,73 +465,6 @@ export default function Dashboard() { )} - {/* Execute Job Modal */} - {showExecuteModal && ( -
setShowExecuteModal(false)} - > -
e.stopPropagation()} - > -

작업 즉시 실행

- - - - - setStartDate(e.target.value)} - className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm - focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-3" - /> - - - setStopDate(e.target.value)} - className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm - focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-4" - /> - -
- - -
-
-
- )} ); } diff --git a/frontend/src/pages/Jobs.tsx b/frontend/src/pages/Jobs.tsx index 04f7b8e..3a35552 100644 --- a/frontend/src/pages/Jobs.tsx +++ b/frontend/src/pages/Jobs.tsx @@ -7,10 +7,44 @@ import { useToastContext } from '../contexts/ToastContext'; import StatusBadge from '../components/StatusBadge'; import EmptyState from '../components/EmptyState'; import LoadingSpinner from '../components/LoadingSpinner'; -import { formatDateTime } from '../utils/formatters'; +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(); @@ -18,14 +52,18 @@ export default function Jobs() { const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState('ALL'); + const [sortKey, setSortKey] = useState('name'); + const [viewMode, setViewMode] = useState('card'); - // Execute modal + // Execute modal (individual card) const [executeModalOpen, setExecuteModalOpen] = useState(false); const [targetJob, setTargetJob] = useState(''); const [executing, setExecuting] = useState(false); const [startDate, setStartDate] = useState(''); const [stopDate, setStopDate] = useState(''); + const loadJobs = useCallback(async () => { try { const data = await batchApi.getJobsDetail(); @@ -39,12 +77,48 @@ export default function Jobs() { usePoller(loadJobs, POLLING_INTERVAL); - const filteredJobs = useMemo(() => { - if (!searchTerm.trim()) return jobs; - const term = searchTerm.toLowerCase(); - return jobs.filter((job) => job.jobName.toLowerCase().includes(term)); + const statusCounts = useMemo(() => { + const searchFiltered = searchTerm.trim() + ? jobs.filter((job) => job.jobName.toLowerCase().includes(searchTerm.toLowerCase())) + : 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)); + } + + result = result.filter((job) => matchesStatusFilter(job, statusFilter)); + + result = [...result].sort((a, b) => { + if (sortKey === 'name') { + return a.jobName.localeCompare(b.jobName); + } + 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); setStartDate(''); @@ -95,38 +169,115 @@ export default function Jobs() { - {/* Search Filter */} + {/* Status Filter Tabs */} +
+ {STATUS_TABS.map((tab) => ( + + ))} +
+ + {/* Search + Sort + View Toggle */}
-
- - - - - - setSearchTerm(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm +
+ {/* 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}개 작업 검색됨 @@ -134,69 +285,206 @@ export default function Jobs() { )}

- {/* Job Cards Grid */} + {/* Job List */} {filteredJobs.length === 0 ? (
- ) : ( + ) : viewMode === 'card' ? ( + /* Card View */
- {filteredJobs.map((job) => ( -
-
-

- {job.jobName} -

- {job.lastExecution && ( - - )} -
+ {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 !== '-'; - {/* Job detail info */} -
- {job.lastExecution ? ( -

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

- ) : ( -

실행 이력 없음

- )} - {job.scheduleCron && ( -

- 스케줄:{' '} - - {job.scheduleCron} - -

- )} -
+ return ( +
+
+

+ {job.jobName} +

+
+ {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 && ( + + )} + {job.jobName} +
+
+ {job.lastExecution ? ( + + ) : ( + 미실행 + )} + + {job.lastExecution + ? formatDateTime(job.lastExecution.startTime) + : '-'} + + {job.lastExecution ? duration : '-'} + + {job.scheduleCron ? ( + + 자동 + + ) : ( + + 수동 + + )} + +
+ + +
+
+
)} @@ -268,6 +556,7 @@ export default function Jobs() {
)} + ); }