import { useState, useMemo, useCallback, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { batchApi, type RecollectionHistoryDto, type RecollectionSearchResponse, type CollectionPeriodDto, type LastCollectionStatusDto, type JobDisplayName, } from '../api/batchApi'; import { formatDateTime, formatDuration } from '../utils/formatters'; import { usePoller } from '../hooks/usePoller'; import { useToastContext } from '../contexts/ToastContext'; import StatusBadge from '../components/StatusBadge'; import InfoModal from '../components/InfoModal'; import EmptyState from '../components/EmptyState'; import LoadingSpinner from '../components/LoadingSpinner'; import GuideModal, { HelpButton } from '../components/GuideModal'; type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED'; const STATUS_FILTERS: { value: StatusFilter; label: string }[] = [ { value: 'ALL', label: '전체' }, { value: 'COMPLETED', label: '완료' }, { value: 'FAILED', label: '실패' }, { value: 'STARTED', label: '실행중' }, ]; const POLLING_INTERVAL_MS = 10_000; const PAGE_SIZE = 20; /** datetime 문자열에서 date input용 값 추출 (YYYY-MM-DD) */ function toDateInput(dt: string | null): string { if (!dt) return ''; return dt.substring(0, 10); } /** datetime 문자열에서 time input용 값 추출 (HH:mm) */ function toTimeInput(dt: string | null): string { if (!dt) return '00:00'; const t = dt.substring(11, 16); return t || '00:00'; } /** date + time을 ISO datetime 문자열로 결합 */ function toIsoDateTime(date: string, time: string): string { return `${date}T${time || '00:00'}:00`; } interface PeriodEdit { fromDate: string; fromTime: string; toDate: string; toTime: string; } // ── 수집 상태 임계값 (분 단위) ──────────────────────────────── const THRESHOLD_WARN_MINUTES = 24 * 60; // 24시간 const THRESHOLD_DANGER_MINUTES = 48 * 60; // 48시간 type CollectionStatusLevel = 'normal' | 'warn' | 'danger'; function getStatusLevel(elapsedMinutes: number): CollectionStatusLevel { if (elapsedMinutes < 0) return 'danger'; if (elapsedMinutes <= THRESHOLD_WARN_MINUTES) return 'normal'; if (elapsedMinutes <= THRESHOLD_DANGER_MINUTES) return 'warn'; return 'danger'; } function getStatusLabel(level: CollectionStatusLevel): string { if (level === 'normal') return '정상'; if (level === 'warn') return '주의'; return '경고'; } function getStatusColor(level: CollectionStatusLevel): string { if (level === 'normal') return 'text-green-500'; if (level === 'warn') return 'text-yellow-500'; return 'text-red-500'; } function getStatusBgColor(level: CollectionStatusLevel): string { if (level === 'normal') return 'bg-green-500/10 text-green-600 dark:text-green-400'; if (level === 'warn') return 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400'; return 'bg-red-500/10 text-red-600 dark:text-red-400'; } function formatElapsed(minutes: number): string { if (minutes < 0) return '-'; if (minutes < 60) return `${minutes}분 전`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}시간 ${minutes % 60}분 전`; const days = Math.floor(hours / 24); const remainHours = hours % 24; return remainHours > 0 ? `${days}일 ${remainHours}시간 전` : `${days}일 전`; } /** 기간 프리셋 정의 (시간 단위) */ const DURATION_PRESETS = [ { label: '6시간', hours: 6 }, { label: '12시간', hours: 12 }, { label: '하루', hours: 24 }, { label: '일주일', hours: 168 }, ] as const; /** 시작 날짜+시간에 시간(hours)을 더해 종료 날짜+시간을 반환 */ function addHoursToDateTime( date: string, time: string, hours: number, ): { toDate: string; toTime: string } { if (!date) return { toDate: '', toTime: '00:00' }; const dt = new Date(`${date}T${time || '00:00'}:00`); dt.setTime(dt.getTime() + hours * 60 * 60 * 1000); const y = dt.getFullYear(); const m = String(dt.getMonth() + 1).padStart(2, '0'); const d = String(dt.getDate()).padStart(2, '0'); const hh = String(dt.getHours()).padStart(2, '0'); const mm = String(dt.getMinutes()).padStart(2, '0'); return { toDate: `${y}-${m}-${d}`, toTime: `${hh}:${mm}` }; } export default function Recollects() { const navigate = useNavigate(); const { showToast } = useToastContext(); const [displayNames, setDisplayNames] = useState([]); const [lastCollectionStatuses, setLastCollectionStatuses] = useState([]); const [lastCollectionPanelOpen, setLastCollectionPanelOpen] = useState(false); const [periods, setPeriods] = useState([]); const [histories, setHistories] = useState([]); const [selectedApiKey, setSelectedApiKey] = useState(''); const [apiDropdownOpen, setApiDropdownOpen] = useState(false); const [statusFilter, setStatusFilter] = useState('ALL'); const [loading, setLoading] = useState(true); // 날짜 범위 필터 + 페이지네이션 const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [page, setPage] = useState(0); const [totalPages, setTotalPages] = useState(0); const [totalCount, setTotalCount] = useState(0); const [useSearch, setUseSearch] = useState(false); // 실패건 수 (jobExecutionId → count) const [failedRecordCounts, setFailedRecordCounts] = useState>({}); // 실패 로그 모달 const [failLogTarget, setFailLogTarget] = useState(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 [selectedPeriodKey, setSelectedPeriodKey] = useState(''); const [periodDropdownOpen, setPeriodDropdownOpen] = useState(false); const [periodEdits, setPeriodEdits] = useState>({}); const [savingApiKey, setSavingApiKey] = useState(null); const [executingApiKey, setExecutingApiKey] = useState(null); const [manualToDate, setManualToDate] = useState>({}); const [selectedDuration, setSelectedDuration] = useState>({}); const getPeriodEdit = (p: CollectionPeriodDto): PeriodEdit => { if (periodEdits[p.apiKey]) return periodEdits[p.apiKey]; return { fromDate: toDateInput(p.rangeFromDate), fromTime: toTimeInput(p.rangeFromDate), toDate: toDateInput(p.rangeToDate), toTime: toTimeInput(p.rangeToDate), }; }; const updatePeriodEdit = (apiKey: string, field: keyof PeriodEdit, value: string) => { const current = periodEdits[apiKey] || getPeriodEdit(periods.find((p) => p.apiKey === apiKey)!); setPeriodEdits((prev) => ({ ...prev, [apiKey]: { ...current, [field]: value } })); }; const applyDurationPreset = (apiKey: string, hours: number) => { const p = periods.find((pp) => pp.apiKey === apiKey); if (!p) return; const edit = periodEdits[apiKey] || getPeriodEdit(p); if (!edit.fromDate) { showToast('재수집 시작일시를 먼저 선택해 주세요.', 'error'); return; } const { toDate, toTime } = addHoursToDateTime(edit.fromDate, edit.fromTime, hours); setSelectedDuration((prev) => ({ ...prev, [apiKey]: hours })); setPeriodEdits((prev) => ({ ...prev, [apiKey]: { ...edit, toDate, toTime }, })); }; const handleFromDateChange = (apiKey: string, field: 'fromDate' | 'fromTime', value: string) => { const p = periods.find((pp) => pp.apiKey === apiKey); if (!p) return; const edit = periodEdits[apiKey] || getPeriodEdit(p); const updated = { ...edit, [field]: value }; // 기간 프리셋이 선택된 상태면 종료일시도 자동 갱신 const dur = selectedDuration[apiKey]; if (dur != null && !manualToDate[apiKey]) { const { toDate, toTime } = addHoursToDateTime(updated.fromDate, updated.fromTime, dur); updated.toDate = toDate; updated.toTime = toTime; } setPeriodEdits((prev) => ({ ...prev, [apiKey]: updated })); }; const handleResetPeriod = async (p: CollectionPeriodDto) => { setSavingApiKey(p.apiKey); try { await batchApi.resetCollectionPeriod(p.apiKey); showToast(`${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 수집 기간이 초기화되었습니다.`, 'success'); setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; }); setSelectedDuration((prev) => ({ ...prev, [p.apiKey]: null })); setManualToDate((prev) => ({ ...prev, [p.apiKey]: false })); await loadPeriods(); } catch (err) { showToast(err instanceof Error ? err.message : '수집 기간 초기화에 실패했습니다.', 'error'); } finally { setSavingApiKey(null); } }; const handleSavePeriod = async (p: CollectionPeriodDto) => { const edit = getPeriodEdit(p); if (!edit.fromDate || !edit.toDate) { showToast('시작일과 종료일을 모두 입력해 주세요.', 'error'); return; } const from = toIsoDateTime(edit.fromDate, edit.fromTime); const to = toIsoDateTime(edit.toDate, edit.toTime); const now = new Date().toISOString().substring(0, 19); if (from >= now) { showToast('재수집 시작일시는 현재 시간보다 이전이어야 합니다.', 'error'); return; } if (to >= now) { showToast('재수집 종료일시는 현재 시간보다 이전이어야 합니다.', 'error'); return; } if (from >= to) { showToast('재수집 시작일시는 종료일시보다 이전이어야 합니다.', 'error'); return; } setSavingApiKey(p.apiKey); try { await batchApi.updateCollectionPeriod(p.apiKey, { rangeFromDate: from, rangeToDate: to }); showToast(`${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 수집 기간이 저장되었습니다.`, 'success'); setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; }); await loadPeriods(); } catch (err) { showToast(err instanceof Error ? err.message : '수집 기간 저장에 실패했습니다.', 'error'); } finally { setSavingApiKey(null); } }; const handleExecuteRecollect = async (p: CollectionPeriodDto) => { if (!p.jobName) { showToast('연결된 Job이 없습니다.', 'error'); return; } setExecutingApiKey(p.apiKey); try { const result = await batchApi.executeJob(p.jobName, { executionMode: 'RECOLLECT', apiKey: p.apiKey, executor: 'MANUAL', reason: '수집 기간 관리 화면에서 수동 실행', }); showToast(result.message || `${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 재수집이 시작되었습니다.`, 'success'); setLoading(true); } catch (err) { showToast(err instanceof Error ? err.message : '재수집 실행에 실패했습니다.', 'error'); } finally { setExecutingApiKey(null); } }; const loadLastCollectionStatuses = useCallback(async () => { try { const data = await batchApi.getLastCollectionStatuses(); setLastCollectionStatuses(data); } catch { /* 수집 성공일시 로드 실패 무시 (폴링 중 반복 에러 toast 방지) */ } }, []); const loadPeriods = useCallback(async () => { try { const data = await batchApi.getCollectionPeriods(); setPeriods(data); } catch { /* 수집기간 로드 실패 무시 (폴링 중 반복 에러 toast 방지) */ } }, []); const loadMetadata = useCallback(async () => { await Promise.all([loadLastCollectionStatuses(), loadPeriods()]); }, [loadLastCollectionStatuses, loadPeriods]); const [initialLoad, setInitialLoad] = useState(true); const loadHistories = useCallback(async () => { try { const params: { apiKey?: string; status?: string; fromDate?: string; toDate?: string; page?: number; size?: number; } = { page: useSearch ? page : 0, size: PAGE_SIZE, }; if (selectedApiKey) params.apiKey = selectedApiKey; if (statusFilter !== 'ALL') params.status = statusFilter; if (useSearch && startDate) params.fromDate = `${startDate}T00:00:00`; if (useSearch && endDate) params.toDate = `${endDate}T23:59:59`; const data: RecollectionSearchResponse = await batchApi.searchRecollections(params); setHistories(data.content); setTotalPages(data.totalPages); setTotalCount(data.totalElements); setFailedRecordCounts(data.failedRecordCounts ?? {}); if (!useSearch) setPage(data.number); setInitialLoad(false); } catch (err) { console.error('이력 조회 실패:', err); /* 초기 로드 실패만 toast, 폴링 중 실패는 console.error만 */ if (initialLoad) { showToast('재수집 이력을 불러오지 못했습니다.', 'error'); } setHistories([]); setTotalPages(0); setTotalCount(0); } finally { setLoading(false); } }, [selectedApiKey, statusFilter, startDate, endDate, useSearch, page, showToast, initialLoad]); usePoller(loadMetadata, 60_000, []); usePoller(loadHistories, POLLING_INTERVAL_MS, [selectedApiKey, statusFilter, useSearch, page]); const collectionStatusSummary = useMemo(() => { let normal = 0, warn = 0, danger = 0; for (const s of lastCollectionStatuses) { const level = getStatusLevel(s.elapsedMinutes); if (level === 'normal') normal++; else if (level === 'warn') warn++; else danger++; } return { normal, warn, danger, total: lastCollectionStatuses.length }; }, [lastCollectionStatuses]); const filteredHistories = useMemo(() => { if (useSearch) return histories; if (statusFilter === 'ALL') return histories; return histories.filter((h) => h.executionStatus === statusFilter); }, [histories, statusFilter, useSearch]); const handleSearch = async () => { setUseSearch(true); setPage(0); setLoading(true); await loadHistories(); }; const handleResetSearch = () => { setUseSearch(false); setStartDate(''); setEndDate(''); setPage(0); setTotalPages(0); setTotalCount(0); setLoading(true); }; const handlePageChange = (newPage: number) => { if (newPage < 0 || newPage >= totalPages) return; setPage(newPage); setLoading(true); }; useEffect(() => { batchApi.getDisplayNames() .then(setDisplayNames) .catch(() => { /* displayName 로드 실패 무시 */ }); }, []); const displayNameMap = useMemo>(() => { const map: Record = {}; for (const dn of displayNames) { if (dn.apiKey) map[dn.apiKey] = dn.displayName; } return map; }, [displayNames]); const getApiLabel = (apiKey: string) => { if (displayNameMap[apiKey]) return displayNameMap[apiKey]; const p = periods.find((p) => p.apiKey === apiKey); return p?.apiKeyName || apiKey; }; return (
{/* 헤더 */}

재수집 이력

setGuideOpen(true)} />

배치 재수집 실행 이력을 조회하고 관리합니다.

{/* 마지막 수집 성공일시 패널 */}
{lastCollectionPanelOpen && (
{lastCollectionStatuses.length === 0 ? (
수집 이력이 없습니다.
) : ( <> {/* 요약 바 */}
정상 {collectionStatusSummary.normal}
주의 {collectionStatusSummary.warn}
경고 {collectionStatusSummary.danger}
기준: 정상 24h 이내 · 주의 24~48h · 경고 48h 초과
{/* 테이블 */}
{lastCollectionStatuses.map((s) => { const level = getStatusLevel(s.elapsedMinutes); return ( ); })}
배치 작업명 마지막 수집 완료일시 경과시간 상태
{displayNameMap[s.apiKey] || s.apiDesc || s.apiKey}
{formatDateTime(s.lastSuccessDate)} {formatElapsed(s.elapsedMinutes)} {level === 'normal' && '●'} {level === 'warn' && '▲'} {level === 'danger' && '■'} {' '}{getStatusLabel(level)}
)}
)}
{/* 수집 기간 관리 패널 */}
{periodPanelOpen && (
{periods.length === 0 ? (
등록된 수집 기간이 없습니다.
) : ( <> {/* 작업 선택 드롭다운 */}
{periodDropdownOpen && ( <>
setPeriodDropdownOpen(false)} />
{periods.map((p) => ( ))}
)}
{/* 선택된 작업의 기간 편집 */} {selectedPeriodKey && (() => { const p = periods.find((pp) => pp.apiKey === selectedPeriodKey); if (!p) return null; const edit = getPeriodEdit(p); const hasChange = !!periodEdits[p.apiKey]; const isSaving = savingApiKey === p.apiKey; const isExecuting = executingApiKey === p.apiKey; const isManual = !!manualToDate[p.apiKey]; const activeDur = selectedDuration[p.apiKey] ?? null; return (
작업명: {p.jobName || '-'}
{/* Line 1: 재수집 시작일시 */}
handleFromDateChange(p.apiKey, 'fromDate', e.target.value)} className="flex-[3] min-w-0 rounded border border-wing-border bg-wing-surface px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" /> handleFromDateChange(p.apiKey, 'fromTime', e.target.value)} className="flex-[2] min-w-0 rounded border border-wing-border bg-wing-surface px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" />
{/* Line 2: 기간 선택 버튼 + 직접입력 토글 */}
{DURATION_PRESETS.map(({ label, hours }) => ( ))}
직접입력
{/* Line 3: 재수집 종료일시 */}
updatePeriodEdit(p.apiKey, 'toDate', e.target.value)} className={`flex-[3] min-w-0 rounded border border-wing-border px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent ${ isManual ? 'bg-wing-surface' : 'bg-wing-card text-wing-muted cursor-not-allowed' }`} /> updatePeriodEdit(p.apiKey, 'toTime', e.target.value)} className={`flex-[2] min-w-0 rounded border border-wing-border px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent ${ isManual ? 'bg-wing-surface' : 'bg-wing-card text-wing-muted cursor-not-allowed' }`} />
); })()} )}
)}
{/* 필터 영역 */}
{/* API 선택 */}
{apiDropdownOpen && ( <>
setApiDropdownOpen(false)} />
{periods.map((p) => ( ))}
)}
{selectedApiKey && ( )}
{selectedApiKey && (
{getApiLabel(selectedApiKey)}
)}
{/* 상태 필터 버튼 그룹 */}
{STATUS_FILTERS.map(({ value, label }) => ( ))}
{/* 날짜 범위 필터 */}
setStartDate(e.target.value)} className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" /> ~ setEndDate(e.target.value)} className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" />
{useSearch && ( )}
{/* 재수집 이력 테이블 */}
{loading ? ( ) : filteredHistories.length === 0 ? ( ) : (
{filteredHistories.map((hist) => ( ))}
재수집 ID 작업명 상태 재수집 시작일시 재수집 종료일시 소요시간 실패건 액션
#{hist.historyId}
{displayNameMap[hist.apiKey] || hist.apiKeyName || hist.apiKey}
{hist.executionStatus === 'FAILED' ? ( ) : ( )} {hist.hasOverlap && ( ! )}
{formatDateTime(hist.rangeFromDate)}
{formatDateTime(hist.rangeToDate)}
{formatDuration(hist.durationMs)} {(() => { const count = hist.jobExecutionId ? (failedRecordCounts[hist.jobExecutionId] ?? 0) : 0; if (hist.executionStatus === 'STARTED') { return -; } return count > 0 ? ( {count}건 ) : ( 0 ); })()}
)} {/* 결과 건수 + 페이지네이션 */} {!loading && filteredHistories.length > 0 && (
총 {totalCount}건
{totalPages > 1 && (
{page + 1} / {totalPages}
)}
)}
{/* 실패 로그 뷰어 모달 */} setFailLogTarget(null)} > {failLogTarget && (

실행 상태

{failLogTarget.executionStatus}

실패 사유

                                {failLogTarget.failureReason || '실패 사유 없음'}
                            
)}
setGuideOpen(false)} />
); }