feat(ui): 각 화면별 사용자 가이드 추가 (#41)

- GuideModal 컴포넌트 신규 생성 (아코디언 방식 가이드 모달 + HelpButton)
- 8개 페이지에 (?) 도움말 버튼 및 화면별 사용자 가이드 추가
  - 대시보드, 작업 목록, 실행 이력, 실행 상세
  - 재수집 이력, 재수집 상세, 스케줄 관리, 타임라인

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
HYOJIN 2026-03-13 16:02:12 +09:00
부모 98e67def93
커밋 033daff378
9개의 변경된 파일387개의 추가작업 그리고 18개의 파일을 삭제

파일 보기

@ -0,0 +1,92 @@
import { useState } from 'react';
interface GuideSection {
title: string;
content: string;
}
interface Props {
open: boolean;
pageTitle: string;
sections: GuideSection[];
onClose: () => void;
}
export default function GuideModal({ open, pageTitle, sections, onClose }: Props) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onClose}>
<div
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-wing-text">{pageTitle} </h3>
<button
onClick={onClose}
className="p-1 text-wing-muted hover:text-wing-text transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
{sections.map((section, i) => (
<GuideAccordion key={i} title={section.title} content={section.content} defaultOpen={i === 0} />
))}
</div>
<div className="flex justify-end mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
</div>
</div>
</div>
);
}
function GuideAccordion({ title, content, defaultOpen }: { title: string; content: string; defaultOpen: boolean }) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="border border-wing-border rounded-lg overflow-hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-wing-text bg-wing-card hover:bg-wing-hover transition-colors text-left"
>
<span>{title}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`w-4 h-4 text-wing-muted transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="px-4 py-3 text-sm text-wing-muted leading-relaxed whitespace-pre-line">
{content}
</div>
)}
</div>
);
}
export function HelpButton({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
title="사용 가이드"
className="inline-flex items-center justify-center w-7 h-7 rounded-full border border-wing-border text-wing-muted hover:text-wing-accent hover:border-wing-accent transition-colors text-sm font-semibold"
>
?
</button>
);
}

파일 보기

@ -15,9 +15,37 @@ import EmptyState from '../components/EmptyState';
import LoadingSpinner from '../components/LoadingSpinner'; import LoadingSpinner from '../components/LoadingSpinner';
import BarChart from '../components/BarChart'; import BarChart from '../components/BarChart';
import { formatDateTime, calculateDuration } from '../utils/formatters'; import { formatDateTime, calculateDuration } from '../utils/formatters';
import GuideModal, { HelpButton } from '../components/GuideModal';
const POLLING_INTERVAL = 5000; const POLLING_INTERVAL = 5000;
const DASHBOARD_GUIDE = [
{
title: '통계 카드',
content: '화면 상단에 전체 스케줄, 활성/비활성 스케줄 수, 전체 작업 수, 최근 24시간 실패 건수를 한눈에 보여줍니다.',
},
{
title: '실행 중인 작업',
content: '현재 실행 중인 배치 작업 목록을 실시간으로 보여줍니다.\n5초마다 자동으로 갱신됩니다.\n오래 실행 중인 작업이 있으면 상단에 경고 배너가 표시되며, "전체 강제 종료" 버튼으로 일괄 중지할 수 있습니다.',
},
{
title: '최근 실행 이력',
content: '최근 완료된 배치 작업 5건을 보여줍니다.\n각 행의 작업명, 상태, 시작 시간, 소요 시간을 확인할 수 있습니다.\n"전체 보기"를 클릭하면 실행 이력 화면으로 이동합니다.',
},
{
title: '최근 실패 이력',
content: '최근 24시간 내 실패한 작업이 있을 때만 표시됩니다.\n실패 원인을 빠르게 파악할 수 있도록 종료 코드와 메시지를 함께 보여줍니다.',
},
{
title: '재수집 현황',
content: '마지막 수집 완료일시를 API별로 보여줍니다.\n최근 5건의 재수집 이력도 함께 확인할 수 있습니다.',
},
{
title: '실행 통계 차트',
content: '최근 30일간의 배치 실행 통계를 바 차트로 보여줍니다.\n초록색은 성공, 빨간색은 실패, 회색은 기타 상태를 나타냅니다.',
},
];
interface StatCardProps { interface StatCardProps {
label: string; label: string;
value: number; value: number;
@ -46,6 +74,7 @@ export default function Dashboard() {
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null); const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [guideOpen, setGuideOpen] = useState(false);
const [abandoning, setAbandoning] = useState(false); const [abandoning, setAbandoning] = useState(false);
const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null); const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null);
@ -142,7 +171,10 @@ export default function Dashboard() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-2xl font-bold text-wing-text"></h1> <div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-wing-text"></h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
</div> </div>
{/* F1: Stale Execution Warning Banner */} {/* F1: Stale Execution Warning Banner */}
@ -452,6 +484,12 @@ export default function Dashboard() {
</section> </section>
)} )}
<GuideModal
open={guideOpen}
pageTitle="대시보드"
sections={DASHBOARD_GUIDE}
onClose={() => setGuideOpen(false)}
/>
</div> </div>
); );
} }

파일 보기

@ -10,9 +10,33 @@ import Pagination from '../components/Pagination';
import DetailStatCard from '../components/DetailStatCard'; import DetailStatCard from '../components/DetailStatCard';
import ApiLogSection from '../components/ApiLogSection'; import ApiLogSection from '../components/ApiLogSection';
import InfoItem from '../components/InfoItem'; import InfoItem from '../components/InfoItem';
import GuideModal, { HelpButton } from '../components/GuideModal';
const POLLING_INTERVAL_MS = 5000; const POLLING_INTERVAL_MS = 5000;
const EXECUTION_DETAIL_GUIDE = [
{
title: '실행 기본 정보',
content: '실행의 시작/종료 시간, 소요 시간, 종료 코드, 에러 메시지 등 기본 정보를 보여줍니다.\n실행 중인 경우 5초마다 자동으로 갱신됩니다.',
},
{
title: '처리 통계',
content: '4개의 통계 카드로 전체 처리 현황을 요약합니다.\n• 읽기(Read): 외부 API에서 조회한 건수\n• 쓰기(Write): DB에 저장된 건수\n• 건너뜀(Skip): 처리하지 않은 건수\n• 필터(Filter): 조건에 의해 제외된 건수',
},
{
title: 'Step 실행 정보',
content: '배치 작업은 하나 이상의 Step으로 구성됩니다.\n각 Step의 상태, 처리 건수, 커밋/롤백 횟수를 확인할 수 있습니다.\nAPI 호출 정보에서는 총 호출 수, 성공/에러 수, 평균 응답 시간을 보여줍니다.',
},
{
title: 'API 호출 로그',
content: '각 Step에서 호출한 외부 API의 상세 로그를 확인할 수 있습니다.\n요청 URL, 응답 코드, 응답 시간 등을 페이지 단위로 조회합니다.',
},
{
title: '실패 건 관리',
content: '처리 중 실패한 레코드가 있으면 목록으로 표시됩니다.\n• 실패 건 재수집: 실패한 데이터를 다시 수집합니다\n• 일괄 RESOLVED: 모든 실패 건을 해결됨으로 처리합니다\n• 재시도 초기화: 재시도 횟수를 초기화하여 자동 재수집 대상에 포함시킵니다',
},
];
interface StepCardProps { interface StepCardProps {
step: StepExecutionDto; step: StepExecutionDto;
jobName: string; jobName: string;
@ -161,6 +185,7 @@ export default function ExecutionDetail() {
const [detail, setDetail] = useState<JobExecutionDetailDto | null>(null); const [detail, setDetail] = useState<JobExecutionDetailDto | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [guideOpen, setGuideOpen] = useState(false);
const isRunning = detail const isRunning = detail
? detail.status === 'STARTED' || detail.status === 'STARTING' ? detail.status === 'STARTED' || detail.status === 'STARTING'
@ -227,9 +252,12 @@ export default function ExecutionDetail() {
<div className="bg-wing-surface rounded-xl shadow-md p-6"> <div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-wing-text"> <div className="flex items-center gap-2">
#{detail.executionId} <h1 className="text-2xl font-bold text-wing-text">
</h1> #{detail.executionId}
</h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
<p className="mt-1 text-sm text-wing-muted"> <p className="mt-1 text-sm text-wing-muted">
{detail.jobName} {detail.jobName}
</p> </p>
@ -341,6 +369,13 @@ export default function ExecutionDetail() {
</div> </div>
)} )}
</div> </div>
<GuideModal
open={guideOpen}
onClose={() => setGuideOpen(false)}
pageTitle="실행 상세"
sections={EXECUTION_DETAIL_GUIDE}
/>
</div> </div>
); );
} }

파일 보기

@ -9,6 +9,7 @@ import ConfirmModal from '../components/ConfirmModal';
import InfoModal from '../components/InfoModal'; import InfoModal from '../components/InfoModal';
import EmptyState from '../components/EmptyState'; import EmptyState from '../components/EmptyState';
import LoadingSpinner from '../components/LoadingSpinner'; import LoadingSpinner from '../components/LoadingSpinner';
import GuideModal, { HelpButton } from '../components/GuideModal';
type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED' | 'STOPPED'; type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED' | 'STOPPED';
@ -24,6 +25,29 @@ const POLLING_INTERVAL_MS = 5000;
const RECENT_LIMIT = 50; const RECENT_LIMIT = 50;
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const EXECUTIONS_GUIDE = [
{
title: '작업 필터',
content: '상단의 드롭다운에서 조회할 작업을 선택할 수 있습니다.\n여러 작업을 동시에 선택할 수 있으며, 단축 버튼으로 빠르게 필터링할 수 있습니다.\n• 전체: 모든 작업 표시\n• AIS 제외: AIS 관련 작업을 제외하고 표시\n• AIS만: AIS 관련 작업만 표시',
},
{
title: '상태 필터',
content: '완료 / 실패 / 실행중 / 중지됨 버튼으로 상태별 필터링이 가능합니다.\n"전체"를 선택하면 모든 상태의 실행 이력을 볼 수 있습니다.',
},
{
title: '날짜 검색',
content: '시작일과 종료일을 지정하여 특정 기간의 실행 이력을 조회할 수 있습니다.\n"검색" 버튼을 클릭하면 조건에 맞는 결과가 표시됩니다.\n"초기화" 버튼으로 검색 조건을 제거하고 최신 이력으로 돌아갑니다.',
},
{
title: '실행 중인 작업 제어',
content: '실행 중인 작업의 행에서 "중지" 또는 "강제 종료" 버튼을 사용할 수 있습니다.\n• 중지: 현재 Step 완료 후 안전하게 종료\n• 강제 종료: 즉시 중단 (데이터 정합성 주의)',
},
{
title: '실패 로그 확인',
content: '상태가 "FAILED"인 행을 클릭하면 실패 상세 정보를 확인할 수 있습니다.\n종료 코드(Exit Code)와 에러 메시지로 실패 원인을 파악하세요.\n상태가 "COMPLETED"이지만 실패 건수가 있으면 경고 아이콘이 표시됩니다.',
},
];
export default function Executions() { export default function Executions() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -53,6 +77,8 @@ export default function Executions() {
// F9: 실패 로그 뷰어 // F9: 실패 로그 뷰어
const [failLogTarget, setFailLogTarget] = useState<JobExecutionDto | null>(null); const [failLogTarget, setFailLogTarget] = useState<JobExecutionDto | null>(null);
const [guideOpen, setGuideOpen] = useState(false);
const { showToast } = useToastContext(); const { showToast } = useToastContext();
useEffect(() => { useEffect(() => {
@ -243,7 +269,10 @@ export default function Executions() {
<div className="space-y-6"> <div className="space-y-6">
{/* 헤더 */} {/* 헤더 */}
<div> <div>
<h1 className="text-2xl font-bold text-wing-text"> </h1> <div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-wing-text"> </h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
<p className="mt-1 text-sm text-wing-muted"> <p className="mt-1 text-sm text-wing-muted">
. .
</p> </p>
@ -610,6 +639,13 @@ export default function Executions() {
onCancel={() => setAbandonTarget(null)} onCancel={() => setAbandonTarget(null)}
/> />
<GuideModal
open={guideOpen}
onClose={() => setGuideOpen(false)}
pageTitle="실행 이력"
sections={EXECUTIONS_GUIDE}
/>
{/* F9: 실패 로그 뷰어 모달 */} {/* F9: 실패 로그 뷰어 모달 */}
<InfoModal <InfoModal
open={failLogTarget !== null} open={failLogTarget !== null}

파일 보기

@ -8,9 +8,29 @@ import StatusBadge from '../components/StatusBadge';
import EmptyState from '../components/EmptyState'; import EmptyState from '../components/EmptyState';
import LoadingSpinner from '../components/LoadingSpinner'; import LoadingSpinner from '../components/LoadingSpinner';
import { formatDateTime, calculateDuration } from '../utils/formatters'; import { formatDateTime, calculateDuration } from '../utils/formatters';
import GuideModal, { HelpButton } from '../components/GuideModal';
const POLLING_INTERVAL = 30000; 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 StatusFilterKey = 'ALL' | 'STARTED' | 'COMPLETED' | 'FAILED' | 'NONE';
type SortKey = 'name' | 'recent' | 'status'; type SortKey = 'name' | 'recent' | 'status';
type ViewMode = 'card' | 'table'; type ViewMode = 'card' | 'table';
@ -56,6 +76,8 @@ export default function Jobs() {
const [sortKey, setSortKey] = useState<SortKey>('recent'); const [sortKey, setSortKey] = useState<SortKey>('recent');
const [viewMode, setViewMode] = useState<ViewMode>('table'); const [viewMode, setViewMode] = useState<ViewMode>('table');
const [guideOpen, setGuideOpen] = useState(false);
// Execute modal (individual card) // Execute modal (individual card)
const [executeModalOpen, setExecuteModalOpen] = useState(false); const [executeModalOpen, setExecuteModalOpen] = useState(false);
const [targetJob, setTargetJob] = useState(''); const [targetJob, setTargetJob] = useState('');
@ -162,7 +184,10 @@ export default function Jobs() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-2xl font-bold text-wing-text"> </h1> <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"> <span className="text-sm text-wing-muted">
{jobs.length} {jobs.length}
</span> </span>
@ -489,6 +514,13 @@ export default function Jobs() {
</div> </div>
)} )}
<GuideModal
open={guideOpen}
pageTitle="배치 작업 목록"
sections={JOBS_GUIDE}
onClose={() => setGuideOpen(false)}
/>
{/* Execute Modal (custom with date params) */} {/* Execute Modal (custom with date params) */}
{executeModalOpen && ( {executeModalOpen && (
<div <div

파일 보기

@ -16,6 +16,7 @@ import Pagination from '../components/Pagination';
import DetailStatCard from '../components/DetailStatCard'; import DetailStatCard from '../components/DetailStatCard';
import ApiLogSection from '../components/ApiLogSection'; import ApiLogSection from '../components/ApiLogSection';
import InfoItem from '../components/InfoItem'; import InfoItem from '../components/InfoItem';
import GuideModal, { HelpButton } from '../components/GuideModal';
const POLLING_INTERVAL_MS = 10_000; const POLLING_INTERVAL_MS = 10_000;
@ -128,6 +129,33 @@ function StepCard({ step, jobName }: { step: StepExecutionDto; jobName: string }
); );
} }
const RECOLLECT_DETAIL_GUIDE = [
{
title: '재수집 기본 정보',
content: '재수집 실행자, 실행일시, 소요 시간, 재수집 사유 등 기본 정보를 보여줍니다.\n재수집 기간(시작~종료)도 함께 확인할 수 있습니다.',
},
{
title: '처리 통계',
content: '재수집 처리 현황을 4개 카드로 요약합니다.\n• 읽기(Read): API에서 조회한 건수\n• 쓰기(Write): DB에 저장된 건수\n• 건너뜀(Skip): 변경 없어 건너뛴 건수\n• API 호출: 외부 API 총 호출 수',
},
{
title: '기간 중복 이력',
content: '동일 기간에 수행된 다른 수집/재수집 이력이 있으면 표시됩니다.\n중복 수집 여부를 확인하여 데이터 정합성을 검증할 수 있습니다.',
},
{
title: 'Step 실행 정보',
content: '배치 작업은 하나 이상의 Step으로 구성됩니다.\n각 Step의 상태, 처리 건수, 커밋/롤백 횟수를 확인할 수 있습니다.\nAPI 호출 정보에서는 총 호출 수, 성공/에러 수, 평균 응답 시간을 보여줍니다.',
},
{
title: 'API 호출 로그',
content: '각 Step에서 호출한 외부 API의 상세 로그를 확인할 수 있습니다.\n요청 URL, 응답 코드, 응답 시간 등을 페이지 단위로 조회합니다.',
},
{
title: '실패 건 관리',
content: '처리 중 실패한 레코드가 있으면 목록으로 표시됩니다.\n• 실패 건 재수집: 실패한 데이터를 다시 수집합니다\n• 일괄 RESOLVED: 모든 실패 건을 해결됨으로 처리합니다\n• 재시도 초기화: 재시도 횟수를 초기화하여 자동 재수집 대상에 포함시킵니다',
},
];
export default function RecollectDetail() { export default function RecollectDetail() {
const { id: paramId } = useParams<{ id: string }>(); const { id: paramId } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@ -138,6 +166,7 @@ export default function RecollectDetail() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]); const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
const [guideOpen, setGuideOpen] = useState(false);
useEffect(() => { useEffect(() => {
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {}); batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
@ -214,9 +243,12 @@ export default function RecollectDetail() {
<div className="bg-wing-surface rounded-xl shadow-md p-6"> <div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-wing-text"> <div className="flex items-center gap-2">
#{history.historyId} <h1 className="text-2xl font-bold text-wing-text">
</h1> #{history.historyId}
</h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
<p className="mt-1 text-sm text-wing-muted"> <p className="mt-1 text-sm text-wing-muted">
{displayNameMap[history.apiKey] || history.apiKeyName || history.apiKey} &middot; {history.jobName} {displayNameMap[history.apiKey] || history.apiKeyName || history.apiKey} &middot; {history.jobName}
</p> </p>
@ -407,6 +439,13 @@ export default function RecollectDetail() {
</div> </div>
)} )}
</div> </div>
<GuideModal
open={guideOpen}
pageTitle="재수집 상세"
sections={RECOLLECT_DETAIL_GUIDE}
onClose={() => setGuideOpen(false)}
/>
</div> </div>
); );
} }

파일 보기

@ -15,6 +15,7 @@ import StatusBadge from '../components/StatusBadge';
import InfoModal from '../components/InfoModal'; import InfoModal from '../components/InfoModal';
import EmptyState from '../components/EmptyState'; import EmptyState from '../components/EmptyState';
import LoadingSpinner from '../components/LoadingSpinner'; import LoadingSpinner from '../components/LoadingSpinner';
import GuideModal, { HelpButton } from '../components/GuideModal';
type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED'; type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED';
@ -147,6 +148,28 @@ export default function Recollects() {
// 실패 로그 모달 // 실패 로그 모달
const [failLogTarget, setFailLogTarget] = useState<RecollectionHistoryDto | null>(null); const [failLogTarget, setFailLogTarget] = useState<RecollectionHistoryDto | null>(null);
// 가이드 모달
const [guideOpen, setGuideOpen] = useState(false);
const RECOLLECTS_GUIDE = [
{
title: '재수집이란?',
content: '재수집은 특정 기간의 데이터를 다시 수집하는 기능입니다.\n수집 누락이나 데이터 오류가 발생했을 때 사용합니다.\n자동 재수집은 시스템이 실패 건을 자동으로 재시도하며, 수동 재수집은 사용자가 직접 요청합니다.',
},
{
title: '마지막 수집 완료 일시',
content: '각 API별 마지막 수집 완료 일시를 보여줍니다.\n이 정보를 참고하여 재수집이 필요한 기간을 판단할 수 있습니다.',
},
{
title: '재수집 기간 관리',
content: '1. "재수집 기간 관리" 영역에서 수집할 작업을 선택합니다\n2. 수집 기간(시작~종료)을 설정합니다\n3. 재수집 사유를 입력합니다 (선택)\n4. "재수집 요청" 버튼을 클릭합니다\n\n기간 설정 시 기존 수집 기간과 중복되면 경고가 표시됩니다.',
},
{
title: '이력 조회',
content: '작업 선택, 상태 필터, 날짜 범위로 재수집 이력을 검색할 수 있습니다.\n상태: 완료(COMPLETED) / 실패(FAILED) / 실행중(STARTED)\n각 행을 클릭하면 상세 화면으로 이동합니다.',
},
];
// 수집 기간 관리 패널 // 수집 기간 관리 패널
const [periodPanelOpen, setPeriodPanelOpen] = useState(false); const [periodPanelOpen, setPeriodPanelOpen] = useState(false);
const [selectedPeriodKey, setSelectedPeriodKey] = useState<string>(''); const [selectedPeriodKey, setSelectedPeriodKey] = useState<string>('');
@ -405,7 +428,10 @@ export default function Recollects() {
<div className="space-y-6"> <div className="space-y-6">
{/* 헤더 */} {/* 헤더 */}
<div> <div>
<h1 className="text-2xl font-bold text-wing-text"> </h1> <div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-wing-text"> </h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
<p className="mt-1 text-sm text-wing-muted"> <p className="mt-1 text-sm text-wing-muted">
. .
</p> </p>
@ -1035,6 +1061,13 @@ export default function Recollects() {
</div> </div>
)} )}
</InfoModal> </InfoModal>
<GuideModal
open={guideOpen}
pageTitle="재수집 이력"
sections={RECOLLECTS_GUIDE}
onClose={() => setGuideOpen(false)}
/>
</div> </div>
); );
} }

파일 보기

@ -6,6 +6,7 @@ import ConfirmModal from '../components/ConfirmModal';
import EmptyState from '../components/EmptyState'; import EmptyState from '../components/EmptyState';
import LoadingSpinner from '../components/LoadingSpinner'; import LoadingSpinner from '../components/LoadingSpinner';
import { getNextExecutions } from '../utils/cronPreview'; import { getNextExecutions } from '../utils/cronPreview';
import GuideModal, { HelpButton } from '../components/GuideModal';
type ScheduleMode = 'new' | 'existing'; type ScheduleMode = 'new' | 'existing';
type ScheduleViewMode = 'card' | 'table'; type ScheduleViewMode = 'card' | 'table';
@ -79,9 +80,31 @@ function getTriggerStateStyle(state: string | null): string {
} }
} }
const SCHEDULES_GUIDE = [
{
title: '스케줄이란?',
content: '스케줄은 배치 작업을 자동으로 실행하는 설정입니다.\nCron 표현식으로 실행 주기를 지정하면 해당 시간에 자동 실행됩니다.\n활성화된 스케줄만 자동 실행되며, 비활성화하면 일시 중지됩니다.',
},
{
title: '스케줄 등록/수정',
content: '"+ 새 스케줄" 버튼 또는 기존 스케줄의 "편집" 버튼을 클릭하면 설정 팝업이 열립니다.\n1. 작업 선택: 자동 실행할 배치 작업을 선택합니다\n2. Cron 표현식: 실행 주기를 설정합니다 (프리셋 버튼으로 간편 설정 가능)\n3. 설명: 스케줄에 대한 메모를 입력합니다 (선택)\n\n"다음 5회 실행 예정" 미리보기로 설정이 올바른지 확인하세요.',
},
{
title: 'Cron 표현식',
content: 'Cron 표현식은 "초 분 시 일 월 요일" 6자리로 구성됩니다.\n예시:\n• 0 0/15 * * * ? → 매 15분마다\n• 0 0 0 * * ? → 매일 자정\n• 0 0 12 * * ? → 매일 정오\n• 0 0 0 ? * MON → 매주 월요일 자정\n\n프리셋 버튼을 활용하면 직접 입력하지 않아도 됩니다.',
},
{
title: '스케줄 관리',
content: '• 편집: 스케줄 설정(Cron, 설명)을 수정합니다\n• 활성화/비활성화: 자동 실행을 켜거나 끕니다\n• 삭제: 스케줄을 완전히 제거합니다\n\n상태 표시:\n• 활성 (초록): 정상 동작 중\n• 비활성 (회색): 일시 중지 상태\n• NORMAL: 트리거 정상\n• PAUSED: 트리거 일시 중지\n• BLOCKED: 이전 실행이 아직 진행 중\n• ERROR: 트리거 오류 발생',
},
];
export default function Schedules() { export default function Schedules() {
const { showToast } = useToastContext(); const { showToast } = useToastContext();
// Guide modal state
const [guideOpen, setGuideOpen] = useState(false);
// Form state // Form state
const [jobs, setJobs] = useState<string[]>([]); const [jobs, setJobs] = useState<string[]>([]);
const [selectedJob, setSelectedJob] = useState(''); const [selectedJob, setSelectedJob] = useState('');
@ -389,14 +412,17 @@ export default function Schedules() {
{/* Schedule List */} {/* Schedule List */}
<div className="bg-wing-surface rounded-xl shadow-lg p-6"> <div className="bg-wing-surface rounded-xl shadow-lg p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-wing-text"> <div className="flex items-center gap-2">
<h2 className="text-lg font-bold text-wing-text">
{schedules.length > 0 && (
<span className="ml-2 text-sm font-normal text-wing-muted"> {schedules.length > 0 && (
({schedules.length}) <span className="ml-2 text-sm font-normal text-wing-muted">
</span> ({schedules.length})
)} </span>
</h2> )}
</h2>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={handleNewSchedule} onClick={handleNewSchedule}
@ -627,6 +653,12 @@ export default function Schedules() {
onCancel={() => setConfirmAction(null)} onCancel={() => setConfirmAction(null)}
/> />
)} )}
<GuideModal
open={guideOpen}
onClose={() => setGuideOpen(false)}
pageTitle="스케줄 관리"
sections={SCHEDULES_GUIDE}
/>
</div> </div>
); );
} }

파일 보기

@ -8,6 +8,7 @@ import { getStatusColor } from '../components/StatusBadge';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import LoadingSpinner from '../components/LoadingSpinner'; import LoadingSpinner from '../components/LoadingSpinner';
import EmptyState from '../components/EmptyState'; import EmptyState from '../components/EmptyState';
import GuideModal, { HelpButton } from '../components/GuideModal';
type ViewType = 'day' | 'week' | 'month'; type ViewType = 'day' | 'week' | 'month';
@ -70,9 +71,31 @@ function isRunning(status: string): boolean {
return status === 'STARTED' || status === 'STARTING'; return status === 'STARTED' || status === 'STARTING';
} }
const TIMELINE_GUIDE = [
{
title: '타임라인이란?',
content: '타임라인은 배치 작업의 실행 스케줄과 결과를 시각적으로 보여주는 화면입니다.\n세로축은 작업 목록, 가로축은 시간대를 나타냅니다.\n각 셀의 색상으로 실행 상태를 한눈에 파악할 수 있습니다.',
},
{
title: '보기 모드',
content: '3가지 보기 모드를 제공합니다.\n• Day: 하루 단위 (시간대별 상세 보기)\n• Week: 일주일 단위\n• Month: 한 달 단위\n\n이전/다음 버튼으로 기간을 이동하고, "오늘" 버튼으로 현재 날짜로 돌아옵니다.',
},
{
title: '색상 범례',
content: '각 셀의 색상은 실행 상태를 나타냅니다.\n• 초록색: 완료 (성공적으로 실행됨)\n• 빨간색: 실패 (오류 발생)\n• 파란색: 실행 중 (현재 진행 중)\n• 보라색: 예정 (아직 실행 전)\n• 회색: 없음 (해당 시간대에 실행 기록 없음)',
},
{
title: '상세 보기',
content: '셀 위에 마우스를 올리면 툴팁으로 작업명, 기간, 상태 등 요약 정보를 보여줍니다.\n셀을 클릭하면 하단에 상세 패널이 열리며, 해당 시간대의 실행 이력 목록을 확인할 수 있습니다.\n"상세" 링크를 클릭하면 실행 상세 화면으로 이동합니다.',
},
];
export default function Timeline() { export default function Timeline() {
const { showToast } = useToastContext(); const { showToast } = useToastContext();
// Guide modal state
const [guideOpen, setGuideOpen] = useState(false);
const [view, setView] = useState<ViewType>('day'); const [view, setView] = useState<ViewType>('day');
const [currentDate, setCurrentDate] = useState(() => new Date()); const [currentDate, setCurrentDate] = useState(() => new Date());
const [periodLabel, setPeriodLabel] = useState(''); const [periodLabel, setPeriodLabel] = useState('');
@ -254,6 +277,9 @@ export default function Timeline() {
> >
</button> </button>
{/* Help */}
<HelpButton onClick={() => setGuideOpen(true)} />
</div> </div>
</div> </div>
@ -476,6 +502,12 @@ export default function Timeline() {
)} )}
</div> </div>
)} )}
<GuideModal
open={guideOpen}
onClose={() => setGuideOpen(false)}
pageTitle="타임라인"
sections={TIMELINE_GUIDE}
/>
</div> </div>
); );
} }