- GuideModal 컴포넌트 신규 생성 (아코디언 방식 가이드 모달 + HelpButton) - 8개 페이지에 (?) 도움말 버튼 및 화면별 사용자 가이드 추가 - 대시보드, 작업 목록, 실행 이력, 실행 상세 - 재수집 이력, 재수집 상세, 스케줄 관리, 타임라인 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1074 lines
61 KiB
TypeScript
1074 lines
61 KiB
TypeScript
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<JobDisplayName[]>([]);
|
|
const [lastCollectionStatuses, setLastCollectionStatuses] = useState<LastCollectionStatusDto[]>([]);
|
|
const [lastCollectionPanelOpen, setLastCollectionPanelOpen] = useState(false);
|
|
|
|
const [periods, setPeriods] = useState<CollectionPeriodDto[]>([]);
|
|
const [histories, setHistories] = useState<RecollectionHistoryDto[]>([]);
|
|
const [selectedApiKey, setSelectedApiKey] = useState('');
|
|
const [apiDropdownOpen, setApiDropdownOpen] = useState(false);
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('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<Record<number, number>>({});
|
|
|
|
// 실패 로그 모달
|
|
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 [selectedPeriodKey, setSelectedPeriodKey] = useState<string>('');
|
|
const [periodDropdownOpen, setPeriodDropdownOpen] = useState(false);
|
|
const [periodEdits, setPeriodEdits] = useState<Record<string, PeriodEdit>>({});
|
|
const [savingApiKey, setSavingApiKey] = useState<string | null>(null);
|
|
const [executingApiKey, setExecutingApiKey] = useState<string | null>(null);
|
|
const [manualToDate, setManualToDate] = useState<Record<string, boolean>>({});
|
|
const [selectedDuration, setSelectedDuration] = useState<Record<string, number | null>>({});
|
|
|
|
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<Record<string, string>>(() => {
|
|
const map: Record<string, string> = {};
|
|
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 (
|
|
<div className="space-y-6">
|
|
{/* 헤더 */}
|
|
<div>
|
|
<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>
|
|
</div>
|
|
|
|
{/* 마지막 수집 성공일시 패널 */}
|
|
<div className="bg-wing-surface rounded-xl shadow-md">
|
|
<button
|
|
onClick={() => setLastCollectionPanelOpen((v) => !v)}
|
|
className="w-full flex items-center justify-between px-6 py-4 hover:bg-wing-hover transition-colors rounded-xl"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<svg
|
|
className={`w-4 h-4 text-wing-muted transition-transform ${lastCollectionPanelOpen ? 'rotate-90' : ''}`}
|
|
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
<span className="text-sm font-semibold text-wing-text">마지막 수집 완료일시</span>
|
|
<span className="text-xs text-wing-muted">({collectionStatusSummary.total}건)</span>
|
|
{collectionStatusSummary.danger > 0 && (
|
|
<span className="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-500/10 text-red-600 dark:text-red-400">
|
|
경고 {collectionStatusSummary.danger}
|
|
</span>
|
|
)}
|
|
{collectionStatusSummary.warn > 0 && (
|
|
<span className="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
|
|
주의 {collectionStatusSummary.warn}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-wing-muted">
|
|
{lastCollectionPanelOpen ? '접기' : '펼치기'}
|
|
</span>
|
|
</button>
|
|
|
|
{lastCollectionPanelOpen && (
|
|
<div className="border-t border-wing-border/50 px-6 py-4 space-y-4">
|
|
{lastCollectionStatuses.length === 0 ? (
|
|
<div className="py-4 text-center text-sm text-wing-muted">
|
|
수집 이력이 없습니다.
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 요약 바 */}
|
|
<div className="flex items-center gap-4 text-sm">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="inline-block w-2.5 h-2.5 rounded-full bg-green-500" />
|
|
<span className="text-wing-muted">정상</span>
|
|
<span className="font-semibold text-wing-text">{collectionStatusSummary.normal}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="inline-block w-2.5 h-2.5 rounded-full bg-yellow-500" />
|
|
<span className="text-wing-muted">주의</span>
|
|
<span className="font-semibold text-wing-text">{collectionStatusSummary.warn}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500" />
|
|
<span className="text-wing-muted">경고</span>
|
|
<span className="font-semibold text-wing-text">{collectionStatusSummary.danger}</span>
|
|
</div>
|
|
<span className="text-xs text-wing-muted ml-auto">
|
|
기준: 정상 24h 이내 · 주의 24~48h · 경고 48h 초과
|
|
</span>
|
|
</div>
|
|
|
|
{/* 테이블 */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-wing-border/50">
|
|
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted">배치 작업명</th>
|
|
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted">마지막 수집 완료일시</th>
|
|
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted">경과시간</th>
|
|
<th className="text-center py-2 px-3 text-xs font-medium text-wing-muted">상태</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{lastCollectionStatuses.map((s) => {
|
|
const level = getStatusLevel(s.elapsedMinutes);
|
|
return (
|
|
<tr key={s.apiKey} className="border-b border-wing-border/30 hover:bg-wing-hover/50 transition-colors">
|
|
<td className="py-2.5 px-3">
|
|
<div className="text-wing-text font-medium">{displayNameMap[s.apiKey] || s.apiDesc || s.apiKey}</div>
|
|
</td>
|
|
<td className="py-2.5 px-3 font-mono text-xs text-wing-muted">
|
|
{formatDateTime(s.lastSuccessDate)}
|
|
</td>
|
|
<td className={`py-2.5 px-3 text-xs font-medium ${getStatusColor(level)}`}>
|
|
{formatElapsed(s.elapsedMinutes)}
|
|
</td>
|
|
<td className="py-2.5 px-3 text-center">
|
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${getStatusBgColor(level)}`}>
|
|
{level === 'normal' && '●'}
|
|
{level === 'warn' && '▲'}
|
|
{level === 'danger' && '■'}
|
|
{' '}{getStatusLabel(level)}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 수집 기간 관리 패널 */}
|
|
<div className="bg-wing-surface rounded-xl shadow-md">
|
|
<button
|
|
onClick={() => setPeriodPanelOpen((v) => !v)}
|
|
className="w-full flex items-center justify-between px-6 py-4 hover:bg-wing-hover transition-colors rounded-xl"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<svg
|
|
className={`w-4 h-4 text-wing-muted transition-transform ${periodPanelOpen ? 'rotate-90' : ''}`}
|
|
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
<span className="text-sm font-semibold text-wing-text">재수집 기간 관리</span>
|
|
<span className="text-xs text-wing-muted">({periods.length}건)</span>
|
|
</div>
|
|
<span className="text-xs text-wing-muted">
|
|
{periodPanelOpen ? '접기' : '펼치기'}
|
|
</span>
|
|
</button>
|
|
|
|
{periodPanelOpen && (
|
|
<div className="border-t border-wing-border/50 px-6 py-4 space-y-4">
|
|
{periods.length === 0 ? (
|
|
<div className="py-4 text-center text-sm text-wing-muted">
|
|
등록된 수집 기간이 없습니다.
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 작업 선택 드롭다운 */}
|
|
<div className="flex items-center gap-3">
|
|
<label className="text-sm font-medium text-wing-text shrink-0">
|
|
작업 선택
|
|
</label>
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setPeriodDropdownOpen((v) => !v)}
|
|
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-surface hover:bg-wing-hover transition-colors min-w-[200px] justify-between"
|
|
>
|
|
<span className={selectedPeriodKey ? 'text-wing-text' : 'text-wing-muted'}>
|
|
{selectedPeriodKey
|
|
? (displayNameMap[selectedPeriodKey] || periods.find((p) => p.apiKey === selectedPeriodKey)?.apiKeyName || selectedPeriodKey)
|
|
: '작업을 선택하세요'}
|
|
</span>
|
|
<svg className={`w-4 h-4 text-wing-muted transition-transform ${periodDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
{periodDropdownOpen && (
|
|
<>
|
|
<div className="fixed inset-0 z-10" onClick={() => setPeriodDropdownOpen(false)} />
|
|
<div className="absolute z-20 mt-1 w-72 max-h-60 overflow-y-auto bg-wing-surface border border-wing-border rounded-lg shadow-lg">
|
|
{periods.map((p) => (
|
|
<button
|
|
key={p.apiKey}
|
|
onClick={() => {
|
|
setSelectedPeriodKey(p.apiKey);
|
|
setPeriodDropdownOpen(false);
|
|
}}
|
|
className={`w-full text-left px-3 py-2 text-sm hover:bg-wing-hover transition-colors ${
|
|
selectedPeriodKey === p.apiKey
|
|
? 'bg-wing-accent/10 text-wing-accent font-medium'
|
|
: 'text-wing-text'
|
|
}`}
|
|
>
|
|
<div>{displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 선택된 작업의 기간 편집 */}
|
|
{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 (
|
|
<div className="bg-wing-card rounded-lg p-4 space-y-3">
|
|
<div className="flex items-center gap-4 text-sm">
|
|
<span className="text-wing-muted">작업명:</span>
|
|
<span className="font-mono text-xs text-wing-text">{p.jobName || '-'}</span>
|
|
</div>
|
|
|
|
{/* Line 1: 재수집 시작일시 */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-wing-muted mb-1">재수집 시작일시</label>
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
type="date"
|
|
value={edit.fromDate}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<input
|
|
type="time"
|
|
value={edit.fromTime}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Line 2: 기간 선택 버튼 + 직접입력 토글 */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{DURATION_PRESETS.map(({ label, hours }) => (
|
|
<button
|
|
key={hours}
|
|
onClick={() => {
|
|
setManualToDate((prev) => ({ ...prev, [p.apiKey]: false }));
|
|
applyDurationPreset(p.apiKey, hours);
|
|
}}
|
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
|
!isManual && activeDur === hours
|
|
? 'bg-wing-accent text-white border-wing-accent shadow-sm'
|
|
: 'bg-wing-surface text-wing-muted border-wing-border hover:bg-wing-hover'
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
<div className="ml-auto flex items-center gap-1.5">
|
|
<span className="text-xs text-wing-muted">직접입력</span>
|
|
<button
|
|
onClick={() => {
|
|
const next = !isManual;
|
|
setManualToDate((prev) => ({ ...prev, [p.apiKey]: next }));
|
|
if (next) {
|
|
setSelectedDuration((prev) => ({ ...prev, [p.apiKey]: null }));
|
|
}
|
|
}}
|
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
|
isManual ? 'bg-wing-accent' : 'bg-wing-border'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
|
|
isManual ? 'translate-x-4' : 'translate-x-0'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Line 3: 재수집 종료일시 */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-wing-muted mb-1">재수집 종료일시</label>
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
type="date"
|
|
value={edit.toDate}
|
|
disabled={!isManual}
|
|
onChange={(e) => 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'
|
|
}`}
|
|
/>
|
|
<input
|
|
type="time"
|
|
value={edit.toTime}
|
|
disabled={!isManual}
|
|
onChange={(e) => 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'
|
|
}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-2 pt-1">
|
|
<button
|
|
onClick={() => handleResetPeriod(p)}
|
|
disabled={isSaving}
|
|
className="px-4 py-2 text-sm font-medium rounded-lg text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
기간 초기화
|
|
</button>
|
|
<button
|
|
onClick={() => handleSavePeriod(p)}
|
|
disabled={isSaving || !hasChange}
|
|
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
|
hasChange
|
|
? 'text-white bg-wing-accent hover:bg-wing-accent/80 shadow-sm'
|
|
: 'text-wing-muted bg-wing-surface border border-wing-border cursor-not-allowed'
|
|
}`}
|
|
>
|
|
{isSaving ? '저장중...' : '기간 저장'}
|
|
</button>
|
|
<button
|
|
onClick={() => handleExecuteRecollect(p)}
|
|
disabled={isExecuting || !p.jobName}
|
|
className="px-4 py-2 text-sm font-medium rounded-lg text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
{isExecuting ? '실행중...' : '재수집 실행'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 필터 영역 */}
|
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
|
<div className="space-y-3">
|
|
{/* API 선택 */}
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<label className="text-sm font-medium text-wing-text shrink-0">
|
|
재수집 작업 선택
|
|
</label>
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setApiDropdownOpen((v) => !v)}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-wing-border bg-wing-surface hover:bg-wing-hover transition-colors"
|
|
>
|
|
{selectedApiKey
|
|
? getApiLabel(selectedApiKey)
|
|
: '전체'}
|
|
<svg className={`w-4 h-4 text-wing-muted transition-transform ${apiDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
|
</button>
|
|
{apiDropdownOpen && (
|
|
<>
|
|
<div className="fixed inset-0 z-10" onClick={() => setApiDropdownOpen(false)} />
|
|
<div className="absolute z-20 mt-1 w-72 max-h-60 overflow-y-auto bg-wing-surface border border-wing-border rounded-lg shadow-lg">
|
|
<button
|
|
onClick={() => { setSelectedApiKey(''); setApiDropdownOpen(false); setPage(0); setLoading(true); }}
|
|
className={`w-full text-left px-3 py-2 text-sm hover:bg-wing-hover transition-colors ${
|
|
!selectedApiKey ? 'bg-wing-accent/10 text-wing-accent font-medium' : 'text-wing-text'
|
|
}`}
|
|
>
|
|
전체
|
|
</button>
|
|
{periods.map((p) => (
|
|
<button
|
|
key={p.apiKey}
|
|
onClick={() => { setSelectedApiKey(p.apiKey); setApiDropdownOpen(false); setPage(0); setLoading(true); }}
|
|
className={`w-full text-left px-3 py-2 text-sm hover:bg-wing-hover transition-colors ${
|
|
selectedApiKey === p.apiKey ? 'bg-wing-accent/10 text-wing-accent font-medium' : 'text-wing-text'
|
|
}`}
|
|
>
|
|
{displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{selectedApiKey && (
|
|
<button
|
|
onClick={() => { setSelectedApiKey(''); setPage(0); setLoading(true); }}
|
|
className="text-xs text-wing-muted hover:text-wing-accent transition-colors"
|
|
>
|
|
초기화
|
|
</button>
|
|
)}
|
|
</div>
|
|
{selectedApiKey && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-wing-accent/15 text-wing-accent rounded-full">
|
|
{getApiLabel(selectedApiKey)}
|
|
<button
|
|
onClick={() => { setSelectedApiKey(''); setPage(0); setLoading(true); }}
|
|
className="hover:text-wing-text transition-colors"
|
|
>
|
|
×
|
|
</button>
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 상태 필터 버튼 그룹 */}
|
|
<div className="flex flex-wrap gap-1">
|
|
{STATUS_FILTERS.map(({ value, label }) => (
|
|
<button
|
|
key={value}
|
|
onClick={() => {
|
|
setStatusFilter(value);
|
|
setPage(0);
|
|
setLoading(true);
|
|
}}
|
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
|
statusFilter === value
|
|
? 'bg-wing-accent text-white shadow-sm'
|
|
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 날짜 범위 필터 */}
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center mt-4 pt-4 border-t border-wing-border/50">
|
|
<label className="text-sm font-medium text-wing-text shrink-0">
|
|
기간 검색
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="date"
|
|
value={startDate}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<span className="text-wing-muted text-sm">~</span>
|
|
<input
|
|
type="date"
|
|
value={endDate}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleSearch}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors shadow-sm"
|
|
>
|
|
검색
|
|
</button>
|
|
{useSearch && (
|
|
<button
|
|
onClick={handleResetSearch}
|
|
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={() => batchApi.exportRecollectionHistories({
|
|
apiKey: selectedApiKey || undefined,
|
|
status: statusFilter !== 'ALL' ? statusFilter : undefined,
|
|
fromDate: useSearch && startDate ? `${startDate}T00:00:00` : undefined,
|
|
toDate: useSearch && endDate ? `${endDate}T23:59:59` : undefined,
|
|
})}
|
|
className="px-4 py-2 text-sm font-medium text-wing-text border border-wing-border rounded-lg hover:bg-wing-hover transition-colors"
|
|
>
|
|
CSV 내보내기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 재수집 이력 테이블 */}
|
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
|
{loading ? (
|
|
<LoadingSpinner />
|
|
) : filteredHistories.length === 0 ? (
|
|
<EmptyState
|
|
message="재수집 이력이 없습니다."
|
|
sub={
|
|
statusFilter !== 'ALL'
|
|
? '다른 상태 필터를 선택해 보세요.'
|
|
: selectedApiKey
|
|
? '선택한 API의 재수집 이력이 없습니다.'
|
|
: undefined
|
|
}
|
|
/>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm text-left">
|
|
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
|
|
<tr>
|
|
<th className="px-4 py-3 font-medium">재수집 ID</th>
|
|
<th className="px-4 py-3 font-medium">작업명</th>
|
|
<th className="px-4 py-3 font-medium">상태</th>
|
|
<th className="px-4 py-3 font-medium">재수집 시작일시</th>
|
|
<th className="px-4 py-3 font-medium">재수집 종료일시</th>
|
|
<th className="px-4 py-3 font-medium">소요시간</th>
|
|
<th className="px-4 py-3 font-medium text-center">실패건</th>
|
|
<th className="px-4 py-3 font-medium text-right">액션</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-wing-border/50">
|
|
{filteredHistories.map((hist) => (
|
|
<tr
|
|
key={hist.historyId}
|
|
className="hover:bg-wing-hover transition-colors"
|
|
>
|
|
<td className="px-4 py-4 font-mono text-wing-text">
|
|
#{hist.historyId}
|
|
</td>
|
|
<td className="px-4 py-4 text-wing-text">
|
|
<div className="max-w-[120px] truncate" title={displayNameMap[hist.apiKey] || hist.apiKeyName || hist.apiKey}>
|
|
{displayNameMap[hist.apiKey] || hist.apiKeyName || hist.apiKey}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-4">
|
|
{hist.executionStatus === 'FAILED' ? (
|
|
<button
|
|
onClick={() => setFailLogTarget(hist)}
|
|
className="cursor-pointer"
|
|
title="클릭하여 실패 로그 확인"
|
|
>
|
|
<StatusBadge status={hist.executionStatus} />
|
|
</button>
|
|
) : (
|
|
<StatusBadge status={hist.executionStatus} />
|
|
)}
|
|
{hist.hasOverlap && (
|
|
<span className="ml-1 text-xs text-amber-500" title="기간 중복 감지">
|
|
!</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-4 text-wing-muted whitespace-nowrap text-xs">
|
|
<div>{formatDateTime(hist.rangeFromDate)}</div>
|
|
</td>
|
|
<td className="px-4 py-4 text-wing-muted whitespace-nowrap text-xs">
|
|
<div>{formatDateTime(hist.rangeToDate)}</div>
|
|
</td>
|
|
<td className="px-4 py-4 text-wing-muted whitespace-nowrap">
|
|
{formatDuration(hist.durationMs)}
|
|
</td>
|
|
<td className="px-4 py-4 text-center">
|
|
{(() => {
|
|
const count = hist.jobExecutionId
|
|
? (failedRecordCounts[hist.jobExecutionId] ?? 0)
|
|
: 0;
|
|
if (hist.executionStatus === 'STARTED') {
|
|
return <span className="text-xs text-wing-muted">-</span>;
|
|
}
|
|
return count > 0 ? (
|
|
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-700">
|
|
{count}건
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-wing-muted">0</span>
|
|
);
|
|
})()}
|
|
</td>
|
|
<td className="px-4 py-4 text-right">
|
|
<button
|
|
onClick={() => navigate(`/recollects/${hist.historyId}`)}
|
|
className="inline-flex items-center gap-1 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>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* 결과 건수 + 페이지네이션 */}
|
|
{!loading && filteredHistories.length > 0 && (
|
|
<div className="px-6 py-3 bg-wing-card border-t border-wing-border/50 flex items-center justify-between">
|
|
<div className="text-xs text-wing-muted">
|
|
총 {totalCount}건
|
|
</div>
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => handlePageChange(page - 1)}
|
|
disabled={page === 0}
|
|
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
|
|
>
|
|
이전
|
|
</button>
|
|
<span className="text-xs text-wing-muted">
|
|
{page + 1} / {totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => handlePageChange(page + 1)}
|
|
disabled={page >= totalPages - 1}
|
|
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
|
|
>
|
|
다음
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 실패 로그 뷰어 모달 */}
|
|
<InfoModal
|
|
open={failLogTarget !== null}
|
|
title={
|
|
failLogTarget
|
|
? `실패 로그 - #${failLogTarget.historyId} (${failLogTarget.jobName})`
|
|
: '실패 로그'
|
|
}
|
|
onClose={() => setFailLogTarget(null)}
|
|
>
|
|
{failLogTarget && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
|
|
실행 상태
|
|
</h4>
|
|
<p className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg">
|
|
{failLogTarget.executionStatus}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
|
|
실패 사유
|
|
</h4>
|
|
<pre className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
|
|
{failLogTarget.failureReason || '실패 사유 없음'}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</InfoModal>
|
|
|
|
<GuideModal
|
|
open={guideOpen}
|
|
pageTitle="재수집 이력"
|
|
sections={RECOLLECTS_GUIDE}
|
|
onClose={() => setGuideOpen(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|