snp-batch-validation/frontend/src/pages/Jobs.tsx
HYOJIN 033daff378 feat(ui): 각 화면별 사용자 가이드 추가 (#41)
- GuideModal 컴포넌트 신규 생성 (아코디언 방식 가이드 모달 + HelpButton)
- 8개 페이지에 (?) 도움말 버튼 및 화면별 사용자 가이드 추가
  - 대시보드, 작업 목록, 실행 이력, 실행 상세
  - 재수집 이력, 재수집 상세, 스케줄 관리, 타임라인

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:02:12 +09:00

563 lines
22 KiB
TypeScript

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<string, number> = {
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<JobDetailDto[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>('ALL');
const [sortKey, setSortKey] = useState<SortKey>('recent');
const [viewMode, setViewMode] = useState<ViewMode>('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<Record<StatusFilterKey, number>>(
(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 <LoadingSpinner />;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-wing-text"> </h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
<span className="text-sm text-wing-muted">
{jobs.length}
</span>
</div>
{/* Status Filter Tabs */}
<div className="flex flex-wrap gap-2">
{STATUS_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setStatusFilter(tab.key)}
className={`inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
statusFilter === tab.key
? 'bg-wing-accent text-white'
: 'bg-wing-card text-wing-muted hover:text-wing-text'
}`}
>
{tab.label}
<span
className={`inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full text-xs font-semibold ${
statusFilter === tab.key
? 'bg-white/25 text-white'
: 'bg-wing-border text-wing-muted'
}`}
>
{statusCounts[tab.key]}
</span>
</button>
))}
</div>
{/* Search + Sort + View Toggle */}
<div className="bg-wing-surface rounded-xl shadow-md p-4">
<div className="flex gap-3 items-center flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-[200px]">
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</span>
<input
type="text"
placeholder="작업명 검색..."
value={searchTerm}
onChange={(e) => 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 && (
<button
onClick={() => setSearchTerm('')}
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Sort dropdown */}
<select
value={sortKey}
onChange={(e) => setSortKey(e.target.value as SortKey)}
className="px-3 py-2 border border-wing-border rounded-lg text-sm bg-wing-surface text-wing-text
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
>
<option value="name"></option>
<option value="recent"> </option>
<option value="status">( )</option>
</select>
{/* View mode toggle */}
<div className="flex rounded-lg border border-wing-border overflow-hidden">
<button
onClick={() => setViewMode('table')}
title="테이블 보기"
className={`px-3 py-2 transition-colors ${
viewMode === 'table'
? 'bg-wing-accent text-white'
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<button
onClick={() => setViewMode('card')}
title="카드 보기"
className={`px-3 py-2 transition-colors border-l border-wing-border ${
viewMode === 'card'
? 'bg-wing-accent text-white'
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
/>
</svg>
</button>
</div>
</div>
{searchTerm && (
<p className="mt-2 text-xs text-wing-muted">
{filteredJobs.length}
</p>
)}
</div>
{/* Job List */}
{filteredJobs.length === 0 ? (
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<EmptyState
icon="🔍"
message={searchTerm || statusFilter !== 'ALL' ? '검색 결과가 없습니다.' : '등록된 작업이 없습니다.'}
sub={searchTerm || statusFilter !== 'ALL' ? '다른 검색어나 필터를 사용해 보세요.' : undefined}
/>
</div>
) : viewMode === 'card' ? (
/* Card View */
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{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 (
<div
key={job.jobName}
className={`bg-wing-surface rounded-xl shadow-md p-6
hover:shadow-lg hover:-translate-y-0.5 transition-all
${isRunning ? 'border-l-4 border-emerald-500' : ''}`}
>
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-wing-text break-all leading-tight">
{getJobLabel(job)}
</h3>
</div>
<div className="flex items-center gap-2 ml-2 shrink-0">
{isRunning && (
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
)}
{job.lastExecution && (
<StatusBadge status={job.lastExecution.status} />
)}
</div>
</div>
{/* Job detail info */}
<div className="mb-4 space-y-1">
{job.lastExecution ? (
<>
<p className="text-xs text-wing-muted">
: {formatDateTime(job.lastExecution.startTime)}
</p>
{showDuration && (
<p className="text-xs text-wing-muted">
: {duration}
</p>
)}
{isRunning && !showDuration && (
<p className="text-xs text-emerald-500">
시간: 실행 ...
</p>
)}
</>
) : (
<p className="text-wing-muted text-xs"> </p>
)}
<div className="flex items-center gap-2 pt-0.5">
{job.scheduleCron ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700">
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-wing-card text-wing-muted">
</span>
)}
{job.scheduleCron && (
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded text-wing-muted">
{job.scheduleCron}
</span>
)}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleExecuteClick(job.jobName)}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-wing-accent rounded-lg
hover:bg-wing-accent/80 transition-colors"
>
</button>
<button
onClick={() => handleViewHistory(job.jobName)}
className="flex-1 px-3 py-2 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg
hover:bg-wing-accent/15 transition-colors"
>
</button>
</div>
</div>
);
})}
</div>
) : (
/* Table View */
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border bg-wing-card">
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border">
{filteredJobs.map((job) => {
const isRunning = job.lastExecution?.status === 'STARTED';
const duration = job.lastExecution
? calculateDuration(job.lastExecution.startTime, job.lastExecution.endTime)
: '-';
return (
<tr
key={job.jobName}
className={`hover:bg-wing-hover transition-colors ${
isRunning ? 'border-l-4 border-emerald-500' : ''
}`}
>
<td className="px-4 py-3 font-medium text-wing-text break-all">
<div className="flex items-center gap-2">
{isRunning && (
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shrink-0" />
)}
<span>{getJobLabel(job)}</span>
</div>
</td>
<td className="px-4 py-3">
{job.lastExecution ? (
<StatusBadge status={job.lastExecution.status} />
) : (
<span className="text-xs text-wing-muted"></span>
)}
</td>
<td className="px-4 py-3 text-wing-muted">
{job.lastExecution
? formatDateTime(job.lastExecution.startTime)
: '-'}
</td>
<td className="px-4 py-3 text-wing-muted">
{job.lastExecution ? duration : '-'}
</td>
<td className="px-4 py-3">
{job.scheduleCron ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700">
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-wing-card text-wing-muted">
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<button
onClick={() => handleExecuteClick(job.jobName)}
className="px-3 py-1.5 text-xs font-medium text-white bg-wing-accent rounded-lg
hover:bg-wing-accent/80 transition-colors"
>
</button>
<button
onClick={() => handleViewHistory(job.jobName)}
className="px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg
hover:bg-wing-accent/15 transition-colors"
>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
<GuideModal
open={guideOpen}
pageTitle="배치 작업 목록"
sections={JOBS_GUIDE}
onClose={() => setGuideOpen(false)}
/>
{/* Execute Modal (custom with date params) */}
{executeModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
onClick={() => setExecuteModalOpen(false)}
>
<div
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-wing-text mb-2"> </h3>
<p className="text-wing-muted text-sm mb-4">
&quot;{jobs.find((j) => j.jobName === targetJob)?.displayName || targetJob}&quot; ?
</p>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setExecuteModalOpen(false)}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg
hover:bg-wing-hover transition-colors"
>
</button>
<button
onClick={handleConfirmExecute}
disabled={executing}
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg
hover:bg-wing-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{executing ? '실행 중...' : '실행'}
</button>
</div>
</div>
</div>
)}
</div>
);
}